+++ @fox/core/components/fox-contextual-trigger/fox-contextual-trigger.tsx
... | ... | @@ -0,0 +1,132 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ComponentPropsWithRef, ReactNode } from "react"; | |
| 4 | +import { FoxInfoIcon, FoxQuestionIcon } from "../../icons"; | |
| 5 | +import { cx } from "../../utils"; | |
| 6 | + | |
| 7 | +/** 무엇을 여는 단추인지. 아이콘과 기본 이름이 여기서 갈린다. */ | |
| 8 | +export type FoxContextualTriggerType = "help" | "information"; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 네이티브 `<button>`이 받는 것을 그대로 넘겨받는다 — onFocus·onBlur·onKeyDown·form 등. | |
| 12 | + * 툴팁을 띄우는 자리에서 `onFocus`·`onBlur`가 실제로 필요하다(마우스만 보면 키보드 | |
| 13 | + * 사용자가 툴팁을 볼 수 없다). 의미가 겹치는 것만 걷어내고 아래에서 다시 정의한다. | |
| 14 | + */ | |
| 15 | +type NativeButtonProps = Omit< | |
| 16 | + ComponentPropsWithRef<"button">, | |
| 17 | + "type" | "className" | "children" | "hidden" | "onClick" | |
| 18 | +>; | |
| 19 | + | |
| 20 | +export interface FoxContextualTriggerProps extends NativeButtonProps { | |
| 21 | + /** 무엇을 여는 단추인지. 네이티브 `type`은 언제나 `"button"`으로 고정된다. */ | |
| 22 | + type?: FoxContextualTriggerType; | |
| 23 | + /** | |
| 24 | + * 읽어 줄 이름. 넘기지 않으면 계열이 정한 기본값을 쓴다(`help`는 "도움말", | |
| 25 | + * `information`은 "설명"). | |
| 26 | + * | |
| 27 | + * 아이콘만 있는 단추라 이 글자가 **유일한 이름이다.** 한 화면에 여러 개가 있으면 | |
| 28 | + * "배송비 도움말"처럼 무엇에 대한 것인지 적어 준다 — 같은 이름이 여럿이면 소리로는 | |
| 29 | + * 구분되지 않는다. | |
| 30 | + */ | |
| 31 | + ariaLabel?: string; | |
| 32 | + /** | |
| 33 | + * 이 단추가 설명하는 내용의 id. **툴팁을 여는 자리에 쓴다** — 트리거가 | |
| 34 | + * `aria-describedby`로 툴팁을 가리켜야 보조기술이 그 글을 읽어 준다(`FoxTooltip`· | |
| 35 | + * `FoxTooltipRich`는 스스로 아무 역할도 갖지 않는다). | |
| 36 | + */ | |
| 37 | + describedBy?: string; | |
| 38 | + /** | |
| 39 | + * 눌러서 여는 판이 있을 때 그 판의 id. **팝오버를 여는 자리에 쓴다.** | |
| 40 | + * `expanded`와 함께 넘긴다. | |
| 41 | + */ | |
| 42 | + controls?: string; | |
| 43 | + /** 눌러서 여는 판이 지금 열려 있는지(`aria-expanded`). 팝오버를 여는 자리에서만 쓴다. */ | |
| 44 | + expanded?: boolean; | |
| 45 | + disabled?: boolean; | |
| 46 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 47 | + hidden?: boolean; | |
| 48 | + /** 눌렀을 때. 팝오버처럼 눌러서 여는 판에 쓴다. */ | |
| 49 | + onAction?: () => void; | |
| 50 | + /** | |
| 51 | + * 마우스가 올라오고 벗어날 때. **툴팁을 띄우고 지우는 자리에 쓴다** — 이 단추는 상태를 | |
| 52 | + * 갖지 않으므로 무엇을 보일지는 호출부가 정한다. | |
| 53 | + * | |
| 54 | + * ⚠️ 마우스만 보면 키보드 사용자가 툴팁을 볼 수 없다. 호출부가 `onFocus`·`onBlur`도 함께 | |
| 55 | + * 다뤄야 한다 — 네이티브 속성이라 그대로 넘기면 된다. | |
| 56 | + */ | |
| 57 | + onHoverChange?: (hovered: boolean) => void; | |
| 58 | + /** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 쓴다. */ | |
| 59 | + className?: string; | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */ | |
| 63 | +const TYPE_CLASS: Record<FoxContextualTriggerType, string> = { | |
| 64 | + help: "fox-contextual-trigger--help", | |
| 65 | + information: "fox-contextual-trigger--information", | |
| 66 | +}; | |
| 67 | + | |
| 68 | +/** 계열이 정하는 아이콘. 시안 `ico/Question`·`ico/Info`이고 굵기는 둘 다 regular다. */ | |
| 69 | +const TYPE_ICON: Record<FoxContextualTriggerType, ReactNode> = { | |
| 70 | + help: <FoxQuestionIcon />, | |
| 71 | + information: <FoxInfoIcon />, | |
| 72 | +}; | |
| 73 | + | |
| 74 | +/** 이름을 안 주면 쓰는 기본값. 무엇을 여는 단추인지까지만 말한다. */ | |
| 75 | +const TYPE_LABEL: Record<FoxContextualTriggerType, string> = { | |
| 76 | + help: "도움말", | |
| 77 | + information: "설명", | |
| 78 | +}; | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * @fox 도움말·설명 단추. 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추다. | |
| 82 | + * | |
| 83 | + * 상태를 갖지 않는다 — 무엇을 언제 보여 줄지는 호출부가 정하고, 이 단추는 그 배선을 걸 자리 | |
| 84 | + * (`describedBy`·`controls`·`expanded`)와 손이 닿는 자리를 준다. | |
| 85 | + * | |
| 86 | + * `FoxIconButton`과 다른 조각이다. 저쪽은 폼 컨트롤과 높이를 맞추는 단추라 크기가 | |
| 87 | + * `form(height-*)`을 따르는데(가장 작은 xsm도 32px), 이쪽은 글줄 옆에 붙는 24px 고정이고 | |
| 88 | + * 모서리도 `radius-2`(4px)다. | |
| 89 | + * | |
| 90 | + * 배경은 세 상태 모두 `action-secondary` 계열이다 — 기본이 투명이라 아래 배경이 그대로 | |
| 91 | + * 비치고, hover·pressed에서만 연한 판이 깔린다(`FoxTag`와 같은 규칙). | |
| 92 | + * | |
| 93 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 94 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 95 | + */ | |
| 96 | +export function FoxContextualTrigger({ | |
| 97 | + type = "help", | |
| 98 | + ariaLabel, | |
| 99 | + describedBy, | |
| 100 | + controls, | |
| 101 | + expanded, | |
| 102 | + disabled = false, | |
| 103 | + hidden = false, | |
| 104 | + onAction, | |
| 105 | + onHoverChange, | |
| 106 | + className, | |
| 107 | + ...rest | |
| 108 | +}: FoxContextualTriggerProps) { | |
| 109 | + if (hidden) { | |
| 110 | + return null; | |
| 111 | + } | |
| 112 | + | |
| 113 | + return ( | |
| 114 | + <button | |
| 115 | + {...rest} | |
| 116 | + type="button" | |
| 117 | + disabled={disabled} | |
| 118 | + aria-label={ariaLabel ?? TYPE_LABEL[type]} | |
| 119 | + aria-describedby={describedBy} | |
| 120 | + aria-controls={controls} | |
| 121 | + aria-expanded={expanded} | |
| 122 | + className={cx("fox-contextual-trigger", TYPE_CLASS[type], className)} | |
| 123 | + onClick={onAction} | |
| 124 | + onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined} | |
| 125 | + onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined} | |
| 126 | + > | |
| 127 | + <span className="fox-contextual-trigger__icon" aria-hidden="true"> | |
| 128 | + {TYPE_ICON[type]} | |
| 129 | + </span> | |
| 130 | + </button> | |
| 131 | + ); | |
| 132 | +} |
+++ @fox/core/components/fox-contextual-trigger/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxContextualTrigger, | |
| 3 | + type FoxContextualTriggerProps, | |
| 4 | + type FoxContextualTriggerType, | |
| 5 | +} from "./fox-contextual-trigger"; |
+++ @fox/core/components/fox-popover/fox-popover.tsx
... | ... | @@ -0,0 +1,151 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode, Ref } from "react"; | |
| 4 | +import { FoxCaretRightIcon, FoxXIcon } from "../../icons"; | |
| 5 | +import { cx } from "../../utils"; | |
| 6 | +import { FoxTextButton } from "../fox-text-button"; | |
| 7 | + | |
| 8 | +/** 꼬리가 판의 위에 붙는지 아래에 붙는지. 판이 트리거의 아래/위 어느 쪽에 뜨는지가 따라온다. */ | |
| 9 | +export type FoxPopoverDirection = "top" | "bottom"; | |
| 10 | + | |
| 11 | +/** 꼬리가 판의 가로 어디에 붙는지. */ | |
| 12 | +export type FoxPopoverArrow = "left" | "center" | "right"; | |
| 13 | + | |
| 14 | +export interface FoxPopoverProps { | |
| 15 | + /** | |
| 16 | + * `top`이면 꼬리가 판 위에 붙는다 — 트리거 아래에 뜨는 말풍선이다. | |
| 17 | + * `bottom`이면 꼬리가 판 아래에 붙는다 — 트리거 위에 뜨는 말풍선이다. | |
| 18 | + */ | |
| 19 | + direction?: FoxPopoverDirection; | |
| 20 | + /** 꼬리의 가로 위치. 판 가장자리에서 24px 안쪽에 선다(가운데는 예외). */ | |
| 21 | + arrow?: FoxPopoverArrow; | |
| 22 | + /** 굵은 제목 줄. 넘기지 않으면 렌더링하지 않는다. */ | |
| 23 | + title?: ReactNode; | |
| 24 | + /** 제목 아래 본문. 넘기지 않으면 렌더링하지 않는다. */ | |
| 25 | + message?: ReactNode; | |
| 26 | + /** 아래쪽 글자 버튼의 문구(예: "바로가기"). 없으면 버튼을 그리지 않는다. */ | |
| 27 | + actionLabel?: string; | |
| 28 | + /** 글자 버튼을 눌렀을 때. `actionLabel`이 없으면 의미 없다. */ | |
| 29 | + onAction?: () => void; | |
| 30 | + /** 닫기(X)를 눌렀을 때. **필수다** — 시안이 닫기를 항상 그린다. */ | |
| 31 | + onClose: () => void; | |
| 32 | + /** 닫기 버튼을 읽어 줄 이름. X 아이콘은 장식이라 이 글자가 유일한 이름이다. */ | |
| 33 | + closeLabel?: string; | |
| 34 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 35 | + hidden?: boolean; | |
| 36 | + id?: string; | |
| 37 | + /** 배치 조정용. 트리거 옆에 놓는 위치와 시안의 폭(360px)을 여기로 덮는다. */ | |
| 38 | + className?: string; | |
| 39 | + ref?: Ref<HTMLDivElement>; | |
| 40 | +} | |
| 41 | + | |
| 42 | +const DIRECTION_CLASS: Record<FoxPopoverDirection, string> = { | |
| 43 | + top: "fox-popover--top", | |
| 44 | + bottom: "fox-popover--bottom", | |
| 45 | +}; | |
| 46 | + | |
| 47 | +const ARROW_CLASS: Record<FoxPopoverArrow, string> = { | |
| 48 | + left: "fox-popover--arrow-left", | |
| 49 | + center: "fox-popover--arrow-center", | |
| 50 | + right: "fox-popover--arrow-right", | |
| 51 | +}; | |
| 52 | + | |
| 53 | +/** | |
| 54 | + * @fox 팝오버. 꼬리가 달린 말풍선 판이다. 제목·본문·글자 버튼을 담고 오른쪽 위에 닫기가 붙는다. | |
| 55 | + * | |
| 56 | + * **상태를 갖지 않고 스스로 자리를 잡지도 않는다.** 언제 열리고 닫힐지는 호출부가 정하고, | |
| 57 | + * 트리거 옆 어디에 놓을지는 `className`으로 준다 — `direction`·`arrow`는 꼬리가 어디에 | |
| 58 | + * 붙는지만 정한다(자리가 모자랄 때 위아래를 뒤집는 자동 배치는 없다. 시안이 방향을 값으로 | |
| 59 | + * 주므로 그 판단은 호출부의 몫이다). | |
| 60 | + * | |
| 61 | + * ⚠️ 판을 띄우는 방식은 `FoxSelect`·`FoxChip`의 목록과 같다 — 감싼 요소 기준의 일반 흐름이라 | |
| 62 | + * `overflow: hidden` 조상 안에서는 잘린다. HTML `popover` 속성으로 top layer에 올리는 방법이 | |
| 63 | + * 있지만, 위치를 잡아 주는 CSS Anchor Positioning이 아직 크롬 계열에만 있어 미룬다 — 옮기게 | |
| 64 | + * 되면 셀렉트·칩·툴팁까지 한 번에 옮긴다. | |
| 65 | + * | |
| 66 | + * 꼬리는 **CSS가 그린다** — 이 컴포넌트가 내보내는 것은 빈 요소 하나뿐이다. 시안이 준 SVG는 | |
| 67 | + * 1px 흰 띠로 판의 1px 테두리를 지우는 구조라 소수 배율 화면에서 이음새에 실선이 비쳤고, | |
| 68 | + * 회전한 정사각형을 판에 절반 걸치는 방식으로 바꿨다(까닭은 스타일 파일 상단 참고). | |
| 69 | + * | |
| 70 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 71 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 72 | + */ | |
| 73 | +export function FoxPopover({ | |
| 74 | + direction = "top", | |
| 75 | + arrow = "left", | |
| 76 | + title, | |
| 77 | + message, | |
| 78 | + actionLabel, | |
| 79 | + onAction, | |
| 80 | + onClose, | |
| 81 | + closeLabel = "닫기", | |
| 82 | + hidden = false, | |
| 83 | + id, | |
| 84 | + className, | |
| 85 | + ref, | |
| 86 | +}: FoxPopoverProps) { | |
| 87 | + if (hidden) { | |
| 88 | + return null; | |
| 89 | + } | |
| 90 | + | |
| 91 | + // 꼬리는 CSS가 그린다 — 이 요소는 자리만 잡는다(스타일 파일 참고). | |
| 92 | + const arrowSlot = ( | |
| 93 | + <div className="fox-popover__arrow-area" aria-hidden="true"> | |
| 94 | + <span className="fox-popover__arrow" /> | |
| 95 | + </div> | |
| 96 | + ); | |
| 97 | + | |
| 98 | + return ( | |
| 99 | + <div | |
| 100 | + ref={ref} | |
| 101 | + id={id} | |
| 102 | + className={cx( | |
| 103 | + "fox-popover", | |
| 104 | + DIRECTION_CLASS[direction], | |
| 105 | + ARROW_CLASS[arrow], | |
| 106 | + className | |
| 107 | + )} | |
| 108 | + > | |
| 109 | + {direction === "top" ? arrowSlot : null} | |
| 110 | + | |
| 111 | + <div className="fox-popover__content"> | |
| 112 | + {title === undefined && message === undefined ? null : ( | |
| 113 | + <div className="fox-popover__body"> | |
| 114 | + {title === undefined ? null : ( | |
| 115 | + <div className="fox-popover__title-row"> | |
| 116 | + <strong className="fox-popover__title">{title}</strong> | |
| 117 | + </div> | |
| 118 | + )} | |
| 119 | + {message === undefined ? null : ( | |
| 120 | + <div className="fox-popover__message-row"> | |
| 121 | + <span className="fox-popover__message">{message}</span> | |
| 122 | + </div> | |
| 123 | + )} | |
| 124 | + </div> | |
| 125 | + )} | |
| 126 | + | |
| 127 | + {actionLabel ? ( | |
| 128 | + <FoxTextButton | |
| 129 | + type="secondary" | |
| 130 | + size="sm" | |
| 131 | + label={actionLabel} | |
| 132 | + trailingIcon={<FoxCaretRightIcon />} | |
| 133 | + onAction={onAction} | |
| 134 | + className="fox-popover__action" | |
| 135 | + /> | |
| 136 | + ) : null} | |
| 137 | + | |
| 138 | + <button | |
| 139 | + type="button" | |
| 140 | + className="fox-popover__close" | |
| 141 | + aria-label={closeLabel} | |
| 142 | + onClick={onClose} | |
| 143 | + > | |
| 144 | + <FoxXIcon /> | |
| 145 | + </button> | |
| 146 | + </div> | |
| 147 | + | |
| 148 | + {direction === "bottom" ? arrowSlot : null} | |
| 149 | + </div> | |
| 150 | + ); | |
| 151 | +} |
+++ @fox/core/components/fox-popover/index.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export { | |
| 2 | + FoxPopover, | |
| 3 | + type FoxPopoverProps, | |
| 4 | + type FoxPopoverDirection, | |
| 5 | + type FoxPopoverArrow, | |
| 6 | +} from "./fox-popover"; |
+++ @fox/core/components/fox-tooltip-rich/fox-tooltip-rich.tsx
... | ... | @@ -0,0 +1,118 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +/** 꼬리가 판의 위에 붙는지 아래에 붙는지. 판이 트리거의 아래/위 어느 쪽에 뜨는지가 따라온다. */ | |
| 5 | +export type FoxTooltipRichDirection = "top" | "bottom"; | |
| 6 | + | |
| 7 | +/** 꼬리가 판의 가로 어디에 붙는지. */ | |
| 8 | +export type FoxTooltipRichArrow = "left" | "center" | "right"; | |
| 9 | + | |
| 10 | +export interface FoxTooltipRichProps { | |
| 11 | + /** | |
| 12 | + * `top`이면 꼬리가 판 위에 붙는다 — 트리거 아래에 뜨는 말풍선이다. | |
| 13 | + * `bottom`이면 꼬리가 판 아래에 붙는다 — 트리거 위에 뜨는 말풍선이다. | |
| 14 | + */ | |
| 15 | + direction?: FoxTooltipRichDirection; | |
| 16 | + /** 꼬리의 가로 위치. 판 가장자리에서 24px 안쪽에 선다(가운데는 예외). */ | |
| 17 | + arrow?: FoxTooltipRichArrow; | |
| 18 | + /** 굵은 제목 줄. 넘기지 않으면 렌더링하지 않는다. */ | |
| 19 | + title?: ReactNode; | |
| 20 | + /** | |
| 21 | + * 본문. **150자 내외의 글자만 담는다**(시안 주석) — 버튼·링크 같은 누를 것을 넣지 않는다. | |
| 22 | + * 누를 것이 필요하면 툴팁이 아니라 `FoxPopover`다. | |
| 23 | + */ | |
| 24 | + message?: ReactNode; | |
| 25 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 26 | + hidden?: boolean; | |
| 27 | + id?: string; | |
| 28 | + /** 배치 조정용. 트리거 옆에 놓는 위치와 시안의 폭(360px)을 여기로 덮는다. */ | |
| 29 | + className?: string; | |
| 30 | + ref?: Ref<HTMLDivElement>; | |
| 31 | +} | |
| 32 | + | |
| 33 | +const DIRECTION_CLASS: Record<FoxTooltipRichDirection, string> = { | |
| 34 | + top: "fox-tooltip-rich--top", | |
| 35 | + bottom: "fox-tooltip-rich--bottom", | |
| 36 | +}; | |
| 37 | + | |
| 38 | +const ARROW_CLASS: Record<FoxTooltipRichArrow, string> = { | |
| 39 | + left: "fox-tooltip-rich--arrow-left", | |
| 40 | + center: "fox-tooltip-rich--arrow-center", | |
| 41 | + right: "fox-tooltip-rich--arrow-right", | |
| 42 | +}; | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * @fox 리치 툴팁. 제목과 본문을 담은 말풍선이다. | |
| 46 | + * | |
| 47 | + * `FoxPopover`와 판·꼬리의 구조가 같지만 **누를 것이 하나도 없다** — 닫기도 액션 버튼도 | |
| 48 | + * 없고, 시안 주석대로 150자 내외의 글자만 담는다. 그래서 여닫는 것은 전적으로 트리거의 | |
| 49 | + * 몫이다(가리키거나 포커스가 닿는 동안 보이는 식). 사용자가 눌러야 할 것이 생기면 그때는 | |
| 50 | + * 툴팁이 아니라 팝오버다 — 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없다. | |
| 51 | + * | |
| 52 | + * 테두리도 그 차이를 따른다: 팝오버는 진한 선(`border-neutral-strong`)이고 이쪽은 한 단계 | |
| 53 | + * 옅은 선(`border-neutral-default`)이다. | |
| 54 | + * | |
| 55 | + * **상태를 갖지 않고 스스로 자리를 잡지도 않는다.** 트리거 옆 어디에 놓을지는 `className`으로 | |
| 56 | + * 주고, `direction`·`arrow`는 꼬리가 어디에 붙는지만 정한다. | |
| 57 | + * | |
| 58 | + * ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않는다. 툴팁으로 읽히려면 트리거가 | |
| 59 | + * `aria-describedby`로 이 요소의 `id`를 가리켜야 한다 — 그 배선은 트리거 쪽이 맡는다. | |
| 60 | + * | |
| 61 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 62 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 63 | + */ | |
| 64 | +export function FoxTooltipRich({ | |
| 65 | + direction = "top", | |
| 66 | + arrow = "left", | |
| 67 | + title, | |
| 68 | + message, | |
| 69 | + hidden = false, | |
| 70 | + id, | |
| 71 | + className, | |
| 72 | + ref, | |
| 73 | +}: FoxTooltipRichProps) { | |
| 74 | + if (hidden) { | |
| 75 | + return null; | |
| 76 | + } | |
| 77 | + | |
| 78 | + // 꼬리는 CSS가 그린다 — 이 요소는 자리만 잡는다(스타일 파일 참고). | |
| 79 | + const arrowSlot = ( | |
| 80 | + <div className="fox-tooltip-rich__arrow-area" aria-hidden="true"> | |
| 81 | + <span className="fox-tooltip-rich__arrow" /> | |
| 82 | + </div> | |
| 83 | + ); | |
| 84 | + | |
| 85 | + return ( | |
| 86 | + <div | |
| 87 | + ref={ref} | |
| 88 | + id={id} | |
| 89 | + className={cx( | |
| 90 | + "fox-tooltip-rich", | |
| 91 | + DIRECTION_CLASS[direction], | |
| 92 | + ARROW_CLASS[arrow], | |
| 93 | + className | |
| 94 | + )} | |
| 95 | + > | |
| 96 | + {direction === "top" ? arrowSlot : null} | |
| 97 | + | |
| 98 | + <div className="fox-tooltip-rich__content"> | |
| 99 | + {title === undefined && message === undefined ? null : ( | |
| 100 | + <div className="fox-tooltip-rich__body"> | |
| 101 | + {title === undefined ? null : ( | |
| 102 | + <div className="fox-tooltip-rich__title-row"> | |
| 103 | + <strong className="fox-tooltip-rich__title">{title}</strong> | |
| 104 | + </div> | |
| 105 | + )} | |
| 106 | + {message === undefined ? null : ( | |
| 107 | + <div className="fox-tooltip-rich__message-row"> | |
| 108 | + <span className="fox-tooltip-rich__message">{message}</span> | |
| 109 | + </div> | |
| 110 | + )} | |
| 111 | + </div> | |
| 112 | + )} | |
| 113 | + </div> | |
| 114 | + | |
| 115 | + {direction === "bottom" ? arrowSlot : null} | |
| 116 | + </div> | |
| 117 | + ); | |
| 118 | +} |
+++ @fox/core/components/fox-tooltip-rich/index.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export { | |
| 2 | + FoxTooltipRich, | |
| 3 | + type FoxTooltipRichProps, | |
| 4 | + type FoxTooltipRichDirection, | |
| 5 | + type FoxTooltipRichArrow, | |
| 6 | +} from "./fox-tooltip-rich"; |
+++ @fox/core/components/fox-tooltip/fox-tooltip.tsx
... | ... | @@ -0,0 +1,96 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +/** 꼬리가 판의 위에 붙는지 아래에 붙는지. 판이 트리거의 아래/위 어느 쪽에 뜨는지가 따라온다. */ | |
| 5 | +export type FoxTooltipDirection = "top" | "bottom"; | |
| 6 | + | |
| 7 | +export interface FoxTooltipProps { | |
| 8 | + /** | |
| 9 | + * `top`이면 꼬리가 판 위에 붙는다 — 트리거 아래에 뜨는 말풍선이다. | |
| 10 | + * `bottom`이면 꼬리가 판 아래에 붙는다 — 트리거 위에 뜨는 말풍선이다. | |
| 11 | + * | |
| 12 | + * 가로 위치를 고르는 값은 없다 — 시안이 꼬리를 언제나 가운데에 둔다(`FoxTooltipRich`와 | |
| 13 | + * 다른 점이다). | |
| 14 | + */ | |
| 15 | + direction?: FoxTooltipDirection; | |
| 16 | + /** 보여 줄 글자. 한 줄짜리 짧은 말이다 — 길어지면 `FoxTooltipRich`를 쓴다. */ | |
| 17 | + message: ReactNode; | |
| 18 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 19 | + hidden?: boolean; | |
| 20 | + id?: string; | |
| 21 | + /** 배치 조정용. 트리거 옆에 놓는 위치를 여기로 준다. */ | |
| 22 | + className?: string; | |
| 23 | + ref?: Ref<HTMLDivElement>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const DIRECTION_CLASS: Record<FoxTooltipDirection, string> = { | |
| 27 | + top: "fox-tooltip--top", | |
| 28 | + bottom: "fox-tooltip--bottom", | |
| 29 | +}; | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * @fox 툴팁. 어두운 판에 짧은 글 한 줄을 담은 말풍선이다. | |
| 33 | + * | |
| 34 | + * `FoxTooltipRich`의 작은 형제다 — 제목이 없고, 꼬리가 언제나 가운데이며, 판이 글자만큼만 | |
| 35 | + * 넓어진다. 여기도 **누를 것이 하나도 없다**: 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없다. | |
| 36 | + * | |
| 37 | + * **상태를 갖지 않고 스스로 자리를 잡지도 않는다.** 언제 보일지와 트리거 옆 어디에 놓을지는 | |
| 38 | + * 호출부가 정하고, `direction`은 꼬리가 위아래 중 어디에 붙는지만 정한다. | |
| 39 | + * | |
| 40 | + * ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않는다. 툴팁으로 읽히려면 트리거가 | |
| 41 | + * `aria-describedby`로 이 요소의 `id`를 가리켜야 한다 — 그 배선은 트리거 쪽이 맡는다. | |
| 42 | + * | |
| 43 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 44 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 45 | + */ | |
| 46 | +export function FoxTooltip({ | |
| 47 | + direction = "top", | |
| 48 | + message, | |
| 49 | + hidden = false, | |
| 50 | + id, | |
| 51 | + className, | |
| 52 | + ref, | |
| 53 | +}: FoxTooltipProps) { | |
| 54 | + if (hidden) { | |
| 55 | + return null; | |
| 56 | + } | |
| 57 | + | |
| 58 | + const arrowSlot = ( | |
| 59 | + <div className="fox-tooltip__arrow-area" aria-hidden="true"> | |
| 60 | + <span className="fox-tooltip__arrow"> | |
| 61 | + <ArrowMark /> | |
| 62 | + </span> | |
| 63 | + </div> | |
| 64 | + ); | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <div | |
| 68 | + ref={ref} | |
| 69 | + id={id} | |
| 70 | + className={cx("fox-tooltip", DIRECTION_CLASS[direction], className)} | |
| 71 | + > | |
| 72 | + {direction === "top" ? arrowSlot : null} | |
| 73 | + <div className="fox-tooltip__content">{message}</div> | |
| 74 | + {direction === "bottom" ? arrowSlot : null} | |
| 75 | + </div> | |
| 76 | + ); | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** | |
| 80 | + * 시안 꼬리. 여기서는 `FoxPopover`·`FoxTooltipRich`처럼 CSS로 그리지 않고 SVG를 쓴다 — | |
| 81 | + * 저쪽은 테두리가 있어 1px 이음새를 맞춰야 했지만 이 판은 테두리 없이 한 색이라 그럴 일이 | |
| 82 | + * 없고, 시안의 꼬리 **끝이 살짝 둥글어** CSS 삼각형으로는 그 모양이 나오지 않는다. | |
| 83 | + * | |
| 84 | + * 색은 리터럴 대신 `currentColor`라 스타일이 판과 같은 값을 준다. 마스크가 없어 문서에서 | |
| 85 | + * id가 겹칠 일도 없다. | |
| 86 | + */ | |
| 87 | +function ArrowMark() { | |
| 88 | + return ( | |
| 89 | + <svg viewBox="0 0 12 6" fill="none" aria-hidden="true"> | |
| 90 | + <path | |
| 91 | + d="M12 0H0L4.93934 5.50991C5.52513 6.16336 6.47487 6.16336 7.06066 5.50991L12 0Z" | |
| 92 | + fill="currentColor" | |
| 93 | + /> | |
| 94 | + </svg> | |
| 95 | + ); | |
| 96 | +} |
+++ @fox/core/components/fox-tooltip/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxTooltip, | |
| 3 | + type FoxTooltipProps, | |
| 4 | + type FoxTooltipDirection, | |
| 5 | +} from "./fox-tooltip"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -22,6 +22,7 @@ |
| 22 | 22 |
export * from "./fox-chip-area"; |
| 23 | 23 |
export * from "./fox-chip-select-option"; |
| 24 | 24 |
export * from "./fox-conditional-option"; |
| 25 |
+export * from "./fox-contextual-trigger"; |
|
| 25 | 26 |
export * from "./fox-disclosure"; |
| 26 | 27 |
export * from "./fox-email"; |
| 27 | 28 |
export * from "./fox-file-upload"; |
... | ... | @@ -35,6 +36,7 @@ |
| 35 | 36 |
export * from "./fox-page-header"; |
| 36 | 37 |
export * from "./fox-pagination"; |
| 37 | 38 |
export * from "./fox-phone-number"; |
| 39 |
+export * from "./fox-popover"; |
|
| 38 | 40 |
export * from "./fox-radio"; |
| 39 | 41 |
export * from "./fox-radio-group"; |
| 40 | 42 |
export * from "./fox-radio-icon"; |
... | ... | @@ -60,6 +62,8 @@ |
| 60 | 62 |
export * from "./fox-text-area"; |
| 61 | 63 |
export * from "./fox-text-button"; |
| 62 | 64 |
export * from "./fox-toast"; |
| 65 |
+export * from "./fox-tooltip"; |
|
| 66 |
+export * from "./fox-tooltip-rich"; |
|
| 63 | 67 |
export * from "./fox-toggle-quantity"; |
| 64 | 68 |
export * from "./fox-toggle-switch"; |
| 65 | 69 |
export * from "./fox-list-search"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -16,6 +16,7 @@ |
| 16 | 16 |
import { FoxCheckbox } from "../core/components/fox-checkbox";
|
| 17 | 17 |
import { FoxCheckboxGroup } from "../core/components/fox-checkbox-group";
|
| 18 | 18 |
import { FoxConditionalOption } from "../core/components/fox-conditional-option";
|
| 19 |
+import { FoxContextualTrigger } from "../core/components/fox-contextual-trigger";
|
|
| 19 | 20 |
import { FoxEmail } from "../core/components/fox-email";
|
| 20 | 21 |
import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
|
| 21 | 22 |
import { FoxFormLabel } from "../core/components/fox-form-label";
|
... | ... | @@ -24,6 +25,7 @@ |
| 24 | 25 |
import { FoxLinkButton } from "../core/components/fox-link-button";
|
| 25 | 26 |
import { FoxPagination } from "../core/components/fox-pagination";
|
| 26 | 27 |
import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
|
| 28 |
+import { FoxPopover } from "../core/components/fox-popover";
|
|
| 27 | 29 |
import { FoxRadio } from "../core/components/fox-radio";
|
| 28 | 30 |
import { FoxRadioGroup } from "../core/components/fox-radio-group";
|
| 29 | 31 |
import { FoxRadioIcon } from "../core/components/fox-radio-icon";
|
... | ... | @@ -64,6 +66,8 @@ |
| 64 | 66 |
import { FoxSelectText } from "../core/components/fox-select-text";
|
| 65 | 67 |
import { FoxSegmentedControl } from "../core/components/fox-segmented-control";
|
| 66 | 68 |
import { FoxSegmentedControlItem } from "../core/components/fox-segmented-control-item";
|
| 69 |
+import { FoxTooltip } from "../core/components/fox-tooltip";
|
|
| 70 |
+import { FoxTooltipRich } from "../core/components/fox-tooltip-rich";
|
|
| 67 | 71 |
import { FoxToggleQuantity } from "../core/components/fox-toggle-quantity";
|
| 68 | 72 |
import { FoxPageHeader } from "../core/components/fox-page-header";
|
| 69 | 73 |
import { FoxModal } from "../core/components/fox-modal";
|
... | ... | @@ -707,6 +711,116 @@ |
| 707 | 711 |
<FoxToast message="저장되었습니다." /> |
| 708 | 712 |
</div> |
| 709 | 713 |
) : null} |
| 714 |
+ </> |
|
| 715 |
+ ); |
|
| 716 |
+} |
|
| 717 |
+ |
|
| 718 |
+/** |
|
| 719 |
+ * 트리거와 말풍선을 잇는 예제. 트리거는 상태를 갖지 않으므로 무엇을 언제 보여 줄지와 |
|
| 720 |
+ * 보조기술 배선(`aria-describedby` / `aria-controls`+`aria-expanded`)은 호출부 몫이다. |
|
| 721 |
+ */ |
|
| 722 |
+function ContextualTriggerDemo() {
|
|
| 723 |
+ const [tipOpen, setTipOpen] = useState(false); |
|
| 724 |
+ const [popOpen, setPopOpen] = useState(false); |
|
| 725 |
+ const [richOpen, setRichOpen] = useState(false); |
|
| 726 |
+ |
|
| 727 |
+ return ( |
|
| 728 |
+ <> |
|
| 729 |
+ {/* 툴팁: 가리키거나 포커스가 닿는 동안 보인다. */}
|
|
| 730 |
+ <span style={{ position: "relative", display: "inline-flex" }}>
|
|
| 731 |
+ <FoxContextualTrigger |
|
| 732 |
+ type="help" |
|
| 733 |
+ ariaLabel="배송비 도움말" |
|
| 734 |
+ describedBy="demo-tooltip" |
|
| 735 |
+ onHoverChange={setTipOpen}
|
|
| 736 |
+ onFocus={() => setTipOpen(true)}
|
|
| 737 |
+ onBlur={() => setTipOpen(false)}
|
|
| 738 |
+ /> |
|
| 739 |
+ {/* 꼬리가 가운데라 트리거 가운데에 맞춰 세운다. */}
|
|
| 740 |
+ {tipOpen ? (
|
|
| 741 |
+ <span |
|
| 742 |
+ style={{
|
|
| 743 |
+ position: "absolute", |
|
| 744 |
+ insetBlockStart: "100%", |
|
| 745 |
+ insetInlineStart: "50%", |
|
| 746 |
+ transform: "translateX(-50%)", |
|
| 747 |
+ zIndex: 10, |
|
| 748 |
+ }} |
|
| 749 |
+ > |
|
| 750 |
+ <FoxTooltip |
|
| 751 |
+ id="demo-tooltip" |
|
| 752 |
+ direction="top" |
|
| 753 |
+ message="3만원 이상 무료" |
|
| 754 |
+ /> |
|
| 755 |
+ </span> |
|
| 756 |
+ ) : null} |
|
| 757 |
+ </span> |
|
| 758 |
+ |
|
| 759 |
+ {/* 팝오버: 눌러서 연다. */}
|
|
| 760 |
+ <span style={{ position: "relative", display: "inline-flex" }}>
|
|
| 761 |
+ <FoxContextualTrigger |
|
| 762 |
+ type="information" |
|
| 763 |
+ ariaLabel="정산 기준 설명" |
|
| 764 |
+ controls="demo-popover" |
|
| 765 |
+ expanded={popOpen}
|
|
| 766 |
+ onAction={() => setPopOpen((open) => !open)}
|
|
| 767 |
+ /> |
|
| 768 |
+ {/* 꼬리가 왼쪽 24px 자리에 서므로(꼬리 가운데 35px) 그만큼 당긴다. */}
|
|
| 769 |
+ {popOpen ? (
|
|
| 770 |
+ <span |
|
| 771 |
+ style={{
|
|
| 772 |
+ position: "absolute", |
|
| 773 |
+ insetBlockStart: "100%", |
|
| 774 |
+ insetInlineStart: "50%", |
|
| 775 |
+ marginInlineStart: "-3.5rem", |
|
| 776 |
+ zIndex: 10, |
|
| 777 |
+ }} |
|
| 778 |
+ > |
|
| 779 |
+ <FoxPopover |
|
| 780 |
+ id="demo-popover" |
|
| 781 |
+ direction="top" |
|
| 782 |
+ arrow="left" |
|
| 783 |
+ title="정산 기준" |
|
| 784 |
+ message="매월 말일 마감하여 다음 달 10일에 지급합니다." |
|
| 785 |
+ actionLabel="바로가기" |
|
| 786 |
+ onAction={() => {}}
|
|
| 787 |
+ onClose={() => setPopOpen(false)}
|
|
| 788 |
+ /> |
|
| 789 |
+ </span> |
|
| 790 |
+ ) : null} |
|
| 791 |
+ </span> |
|
| 792 |
+ |
|
| 793 |
+ {/* 리치 툴팁: 툴팁과 같이 가리키는 동안 보이지만, 제목과 긴 본문을 담는다. */}
|
|
| 794 |
+ <span style={{ position: "relative", display: "inline-flex" }}>
|
|
| 795 |
+ <FoxContextualTrigger |
|
| 796 |
+ type="information" |
|
| 797 |
+ ariaLabel="정산 주기 안내" |
|
| 798 |
+ describedBy="demo-tooltip-rich" |
|
| 799 |
+ onHoverChange={setRichOpen}
|
|
| 800 |
+ onFocus={() => setRichOpen(true)}
|
|
| 801 |
+ onBlur={() => setRichOpen(false)}
|
|
| 802 |
+ /> |
|
| 803 |
+ {/* 꼬리가 왼쪽 24px 자리에 서므로(꼬리 가운데 35px) 그만큼 당긴다. */}
|
|
| 804 |
+ {richOpen ? (
|
|
| 805 |
+ <span |
|
| 806 |
+ style={{
|
|
| 807 |
+ position: "absolute", |
|
| 808 |
+ insetBlockStart: "100%", |
|
| 809 |
+ insetInlineStart: "50%", |
|
| 810 |
+ marginInlineStart: "-3.5rem", |
|
| 811 |
+ zIndex: 10, |
|
| 812 |
+ }} |
|
| 813 |
+ > |
|
| 814 |
+ <FoxTooltipRich |
|
| 815 |
+ id="demo-tooltip-rich" |
|
| 816 |
+ direction="top" |
|
| 817 |
+ arrow="left" |
|
| 818 |
+ title="정산 주기" |
|
| 819 |
+ message="매월 말일에 마감하고 다음 달 10일에 지급합니다. 공휴일이 겹치면 다음 영업일로 밀립니다." |
|
| 820 |
+ /> |
|
| 821 |
+ </span> |
|
| 822 |
+ ) : null} |
|
| 823 |
+ </span> |
|
| 710 | 824 |
</> |
| 711 | 825 |
); |
| 712 | 826 |
} |
... | ... | @@ -3893,4 +4007,182 @@ |
| 3893 | 4007 |
}, |
| 3894 | 4008 |
], |
| 3895 | 4009 |
}, |
| 4010 |
+ {
|
|
| 4011 |
+ id: "fox-popover", |
|
| 4012 |
+ name: "FoxPopover", |
|
| 4013 |
+ description: |
|
| 4014 |
+ "Figma 시안(popover 251:1813) 꼬리가 달린 말풍선 판입니다. direction이 꼬리가 판의 위(top)에 붙는지 아래(bottom)에 붙는지, arrow가 가로 어디(left/center/right)에 붙는지 정합니다 — top이면 트리거 아래에 뜨는 말풍선입니다. 자리를 스스로 잡지 않습니다: 트리거 옆 어디에 놓을지는 className으로 주고, 자리가 모자랄 때 위아래를 뒤집는 자동 배치도 없습니다(시안이 방향을 값으로 주므로 그 판단은 호출부 몫입니다). 액션은 FoxTextButton의 secondary·sm에 오른쪽 아이콘입니다. 꼬리의 가로 위치는 루트의 align-items가 정합니다(시안 지정) — 꼬리 영역이 좌우 24px 여백을 달고 다녀서 왼쪽·오른쪽에서는 그만큼 안쪽에 서고, 가운데에서는 여백이 대칭이라 정확히 가운데에 섭니다. 판은 align-self: stretch라 이 값에 영향받지 않고 언제나 폭을 꽉 채웁니다.", |
|
| 4015 |
+ variants: [ |
|
| 4016 |
+ {
|
|
| 4017 |
+ label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)", |
|
| 4018 |
+ node: ( |
|
| 4019 |
+ <> |
|
| 4020 |
+ <FoxPopover |
|
| 4021 |
+ direction="bottom" |
|
| 4022 |
+ title="팝오버 제목" |
|
| 4023 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4024 |
+ actionLabel="바로가기" |
|
| 4025 |
+ onAction={() => {}}
|
|
| 4026 |
+ onClose={() => {}}
|
|
| 4027 |
+ /> |
|
| 4028 |
+ <FoxPopover |
|
| 4029 |
+ direction="bottom" |
|
| 4030 |
+ arrow="center" |
|
| 4031 |
+ title="팝오버 제목" |
|
| 4032 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4033 |
+ actionLabel="바로가기" |
|
| 4034 |
+ onAction={() => {}}
|
|
| 4035 |
+ onClose={() => {}}
|
|
| 4036 |
+ /> |
|
| 4037 |
+ <FoxPopover |
|
| 4038 |
+ direction="bottom" |
|
| 4039 |
+ arrow="right" |
|
| 4040 |
+ title="팝오버 제목" |
|
| 4041 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4042 |
+ actionLabel="바로가기" |
|
| 4043 |
+ onAction={() => {}}
|
|
| 4044 |
+ onClose={() => {}}
|
|
| 4045 |
+ /> |
|
| 4046 |
+ </> |
|
| 4047 |
+ |
|
| 4048 |
+ ), |
|
| 4049 |
+ }, |
|
| 4050 |
+ {
|
|
| 4051 |
+ label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)", |
|
| 4052 |
+ node: ( |
|
| 4053 |
+ <> |
|
| 4054 |
+ <FoxPopover |
|
| 4055 |
+ direction="top" |
|
| 4056 |
+ arrow="left" |
|
| 4057 |
+ title="팝오버 제목" |
|
| 4058 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4059 |
+ actionLabel="바로가기" |
|
| 4060 |
+ onAction={() => {}}
|
|
| 4061 |
+ onClose={() => {}}
|
|
| 4062 |
+ /> |
|
| 4063 |
+ <FoxPopover |
|
| 4064 |
+ direction="top" |
|
| 4065 |
+ arrow="center" |
|
| 4066 |
+ title="팝오버 제목" |
|
| 4067 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4068 |
+ actionLabel="바로가기" |
|
| 4069 |
+ onAction={() => {}}
|
|
| 4070 |
+ onClose={() => {}}
|
|
| 4071 |
+ /> |
|
| 4072 |
+ <FoxPopover |
|
| 4073 |
+ direction="top" |
|
| 4074 |
+ arrow="right" |
|
| 4075 |
+ title="팝오버 제목" |
|
| 4076 |
+ message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다." |
|
| 4077 |
+ actionLabel="바로가기" |
|
| 4078 |
+ onAction={() => {}}
|
|
| 4079 |
+ onClose={() => {}}
|
|
| 4080 |
+ /> |
|
| 4081 |
+ </> |
|
| 4082 |
+ ), |
|
| 4083 |
+ }, |
|
| 4084 |
+ ], |
|
| 4085 |
+ }, |
|
| 4086 |
+ {
|
|
| 4087 |
+ id: "fox-tooltip-rich", |
|
| 4088 |
+ name: "FoxTooltipRich", |
|
| 4089 |
+ description: |
|
| 4090 |
+ "Figma 시안(tooltip-rich 256:233) 제목과 본문을 담은 말풍선입니다. 150자 내외의 글자만 담습니다. 그래서 여닫는 것은 전적으로 트리거의 몫입니다(가리키거나 포커스가 닿는 동안 보이는 식). 팝오버와 다른 점은 셋뿐입니다: 테두리가 한 단계 옅고(border-neutral-default), 제목과 본문 사이가 좁으며(8px ↔ 16px), 그림자가 없습니다. 꼬리는 팝오버와 같은 그림이라 _bubble.scss의 arrow() 믹스인을 함께 씁니다 — 회전한 정사각형의 절반을 판에 걸쳐 이음새를 없애는 방식입니다. ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않습니다: 툴팁으로 읽히려면 트리거가 aria-describedby로 id를 가리켜야 하고, 그 배선은 트리거 쪽 몫입니다.", |
|
| 4091 |
+ variants: [ |
|
| 4092 |
+ {
|
|
| 4093 |
+ label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)", |
|
| 4094 |
+ node: ( |
|
| 4095 |
+ <> |
|
| 4096 |
+ <FoxTooltipRich |
|
| 4097 |
+ direction="bottom" |
|
| 4098 |
+ title="툴팁제목" |
|
| 4099 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4100 |
+ /> |
|
| 4101 |
+ <FoxTooltipRich |
|
| 4102 |
+ direction="bottom" |
|
| 4103 |
+ arrow="center" |
|
| 4104 |
+ title="툴팁제목" |
|
| 4105 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4106 |
+ /> |
|
| 4107 |
+ <FoxTooltipRich |
|
| 4108 |
+ direction="bottom" |
|
| 4109 |
+ arrow="right" |
|
| 4110 |
+ title="툴팁제목" |
|
| 4111 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4112 |
+ /> |
|
| 4113 |
+ </> |
|
| 4114 |
+ ), |
|
| 4115 |
+ }, |
|
| 4116 |
+ {
|
|
| 4117 |
+ label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)", |
|
| 4118 |
+ node: ( |
|
| 4119 |
+ <> |
|
| 4120 |
+ <FoxTooltipRich |
|
| 4121 |
+ direction="top" |
|
| 4122 |
+ title="툴팁제목" |
|
| 4123 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4124 |
+ /> |
|
| 4125 |
+ <FoxTooltipRich |
|
| 4126 |
+ direction="top" |
|
| 4127 |
+ arrow="center" |
|
| 4128 |
+ title="툴팁제목" |
|
| 4129 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4130 |
+ /> |
|
| 4131 |
+ <FoxTooltipRich |
|
| 4132 |
+ direction="top" |
|
| 4133 |
+ arrow="right" |
|
| 4134 |
+ title="툴팁제목" |
|
| 4135 |
+ message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다." |
|
| 4136 |
+ /> |
|
| 4137 |
+ </> |
|
| 4138 |
+ ), |
|
| 4139 |
+ }, |
|
| 4140 |
+ ], |
|
| 4141 |
+ }, |
|
| 4142 |
+ {
|
|
| 4143 |
+ id: "fox-tooltip", |
|
| 4144 |
+ name: "FoxTooltip", |
|
| 4145 |
+ description: |
|
| 4146 |
+ "Figma 시안(tooltip) 어두운 판에 짧은 글 한 줄을 담은 말풍선입니다. FoxTooltipRich의 작은 형제로 제목이 없고, 꼬리가 언제나 가운데이며(가로 위치를 고르는 값이 없습니다), 판이 글자만큼만 넓어집니다. 여기도 누를 것이 하나도 없습니다 — 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없습니다. 꼬리는 팝오버·리치 툴팁과 달리 시안 SVG를 그대로 씁니다: 저쪽은 테두리 있는 판의 1px 이음새를 지우려고 CSS로 그렸지만 이 판은 테두리 없이 한 색이라 그럴 일이 없고, 시안 꼬리가 12×6에 끝이 둥근 다른 그림이라 CSS 삼각형으로는 모양이 나오지 않습니다. 같은 그림을 180° 돌려 두 방향을 만듭니다. ⚠️ 판이 글자만큼 좁아져도 꼬리 영역의 좌우 24px 여백 때문에 가장 좁을 때가 60px입니다(시안 값). ⚠️ 이 조각도 보조기술에 역할을 주지 않습니다 — 트리거가 aria-describedby로 id를 가리켜야 합니다.", |
|
| 4147 |
+ variants: [ |
|
| 4148 |
+ {
|
|
| 4149 |
+ label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)", |
|
| 4150 |
+ node: <FoxTooltip direction="top" message="툴팁 내용" />, |
|
| 4151 |
+ }, |
|
| 4152 |
+ {
|
|
| 4153 |
+ label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)", |
|
| 4154 |
+ node: <FoxTooltip direction="bottom" message="툴팁 내용" />, |
|
| 4155 |
+ }, |
|
| 4156 |
+ {
|
|
| 4157 |
+ label: "글자 길이에 따라 판이 넓어집니다 (가장 좁을 때 60px)", |
|
| 4158 |
+ node: ( |
|
| 4159 |
+ <> |
|
| 4160 |
+ <FoxTooltip message="짧게" /> |
|
| 4161 |
+ <FoxTooltip message="조금 더 긴 안내 문구입니다" /> |
|
| 4162 |
+ </> |
|
| 4163 |
+ ), |
|
| 4164 |
+ }, |
|
| 4165 |
+ ], |
|
| 4166 |
+ }, |
|
| 4167 |
+ {
|
|
| 4168 |
+ id: "fox-contextual-trigger", |
|
| 4169 |
+ name: "FoxContextualTrigger", |
|
| 4170 |
+ description: |
|
| 4171 |
+ "Figma 시안(contextual-trigger) 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추입니다. 계열이 아이콘을 정하고(help는 ico/Question, information은 ico/Info, 둘 다 weight=regular) 상태는 배경만 바꿉니다 — 기본이 action-secondary(투명)라 아래 배경이 그대로 비치고 hover·pressed에서만 연한 판이 깔립니다(FoxTag와 같은 규칙). FoxIconButton과는 다른 조각입니다: 저쪽은 폼 컨트롤과 높이를 맞추느라 가장 작은 xsm도 32px인데 이쪽은 글줄 옆에 붙는 24px 고정이고 모서리도 4px입니다. 상태를 갖지 않으므로 무엇을 언제 보여 줄지는 호출부가 정하고, 이 단추는 배선을 걸 자리(describedBy / controls·expanded)와 손이 닿는 자리를 줍니다. 아이콘만 있는 단추라 aria-label이 유일한 이름입니다 — 한 화면에 여럿이면 '배송비 도움말'처럼 무엇에 대한 것인지 적어 줍니다. ⚠️ 비활성은 시안에 없어 색을 만들지 않고 hover·pressed만 막았습니다.", |
|
| 4172 |
+ variants: [ |
|
| 4173 |
+ {
|
|
| 4174 |
+ label: "계열 2종 — hover·pressed는 마우스를 올리고 눌러서 확인하세요", |
|
| 4175 |
+ node: ( |
|
| 4176 |
+ <> |
|
| 4177 |
+ <FoxContextualTrigger type="help" /> |
|
| 4178 |
+ <FoxContextualTrigger type="information" /> |
|
| 4179 |
+ </> |
|
| 4180 |
+ ), |
|
| 4181 |
+ }, |
|
| 4182 |
+ {
|
|
| 4183 |
+ label: "실제 배선 — 가리키면 툴팁 / 누르면 팝오버 / 가리키면 리치 툴팁", |
|
| 4184 |
+ node: <ContextualTriggerDemo />, |
|
| 4185 |
+ }, |
|
| 4186 |
+ ], |
|
| 4187 |
+ }, |
|
| 3896 | 4188 |
]; |
+++ @fox/styles/_bubble.scss
... | ... | @@ -0,0 +1,127 @@ |
| 1 | +// 말풍선 공용 조각. | |
| 2 | +// | |
| 3 | +// 꼬리가 달린 판(`FoxPopover` · `FoxTooltipRich`)이 같은 그림을 쓴다. 그 되풀이를 여기로 모은다. | |
| 4 | +// 믹스인만 있고 규칙이 없어 @use 해도 CSS가 늘지 않는다 — 부르는 자리에서만 나온다. | |
| 5 | +// | |
| 6 | +// ── 꼬리를 CSS로 그리는 이유 ─────────────────────────────────────────────────── | |
| 7 | +// | |
| 8 | +// 시안은 꼬리를 22×12 SVG로 준다. 그 그림에는 평평한 끝을 덮는 1px 흰 띠가 들어 있는데, | |
| 9 | +// 꼬리를 판 위로 1px 겹쳐 올려 그 띠로 판의 1px 테두리를 지우는 구조다. Figma에서는 맞지만 | |
| 10 | +// 브라우저에서는 **1px 두 개를 정확히 겹쳐야 하는 방식**이라, 소수 배율 화면(윈도우 125% 등)에서 | |
| 11 | +// 둘이 서로 다른 물리 픽셀에 떨어지면 이음새에 실선이 비친다. 실제로 그렇게 보였다. | |
| 12 | +// | |
| 13 | +// 그래서 CSS로 그린다. **회전한 정사각형의 절반을 판 위에 걸치는** 방식이다: | |
| 14 | +// | |
| 15 | +// 1. 한 변이 22 ÷ √2(≈15.56px)인 정사각형을 45° 돌린다 → 밑변 22px, 높이 11px의 마름모 | |
| 16 | +// 2. 그 마름모의 **가운데를 판과 맞닿는 변 위에** 둔다 | |
| 17 | +// 3. 바깥을 향하는 두 변에만 1px 테두리를 준다 (안쪽 두 변은 테두리 없음) | |
| 18 | +// | |
| 19 | +// 그러면 안쪽 절반(11px)이 판의 테두리 선을 자기 배경으로 덮는다 — 1px끼리 맞추는 것이 아니라 | |
| 20 | +// 11px짜리 면이 1px 선을 덮으므로 어떤 배율에서도 이음새가 남지 않는다. 바깥 두 변의 테두리는 | |
| 21 | +// 마름모의 좌우 꼭짓점, 즉 판의 테두리와 같은 높이에서 끝나므로 선이 자연스럽게 이어진다. | |
| 22 | +// | |
| 23 | +// 45°로 돌리면 밑변과 높이의 비가 2:1이라 22×11이 된다. 시안은 22×12이므로 돌린 뒤 세로로 | |
| 24 | +// 12/11만큼 늘인다(CSS 변환은 오른쪽부터 적용되므로 `scaleY(...) rotate(45deg)` 순서다). | |
| 25 | +// 빗변 테두리가 그만큼 두꺼워지지만 1.09배라 눈에 띄지 않는다. | |
| 26 | +// | |
| 27 | +// SVG를 쓰지 않으니 마스크 id가 문서에서 겹칠 일도 없다(시안 SVG를 그대로 넣으면 말풍선이 | |
| 28 | +// 둘 이상 뜰 때 같은 id를 서로 참조한다). | |
| 29 | +// | |
| 30 | +// 토큰이 아닌 값: | |
| 31 | +// - ⚠️ 꼬리가 차지하는 자리 22×12px: 시안이 준 그림의 크기이고 토큰 스케일에 없다. | |
| 32 | +// - ⚠️ 정사각형의 한 변 `1.5556rem`: 밑변이 22px이 되는 값(22 ÷ √2)이다. 밑변을 바꾸려면 | |
| 33 | +// 이 값을 √2로 나눠 다시 구한다. | |
| 34 | +// - ⚠️ `scaleY(12 / 11)`: 45° 마름모의 높이(11px)를 시안의 12px로 맞추는 보정이다. | |
| 35 | +// - 시안의 `gap: -1px`은 옮기지 않았다 — 정사각형의 안쪽 절반이 이미 판 위에 걸쳐 테두리를 | |
| 36 | +// 덮으므로 따로 당길 필요가 없다. | |
| 37 | + | |
| 38 | +@use "@fox/styles/abstracts" as fox; | |
| 39 | + | |
| 40 | +/// 말풍선 바깥 틀. 꼬리와 판이 세로로 서고, 꼬리의 가로 위치를 이 정렬이 정한다. | |
| 41 | +@mixin root($inline-size: 36rem) { | |
| 42 | + box-sizing: border-box; | |
| 43 | + display: flex; | |
| 44 | + flex-direction: column; | |
| 45 | + inline-size: $inline-size; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// 꼬리와 방향·위치 규칙 한 벌. | |
| 49 | +/// | |
| 50 | +/// `$block`은 BEM 블록 이름(`"fox-popover"` 등)이고, `$border`·`$fill`은 판과 **같은 값**을 | |
| 51 | +/// 넘긴다 — 꼬리는 판이 뾰족하게 튀어나온 부분이라 색이 갈리면 안 된다. | |
| 52 | +/// | |
| 53 | +/// 부르는 쪽은 다음 마크업을 갖는다: | |
| 54 | +/// <div class="…__arrow-area"><span class="…__arrow"></span></div> | |
| 55 | +@mixin arrow($block, $border, $fill) { | |
| 56 | + $root: ".#{$block}"; | |
| 57 | + | |
| 58 | + // 좌우 여백이 꼬리를 판 가장자리에서 띄운다. **늘리지 않는다** — 폭이 내용(22px + 여백 | |
| 59 | + // 48px)만큼이라야 루트의 `align-items`가 이 덩어리를 왼쪽·가운데·오른쪽으로 옮길 수 있다. | |
| 60 | + // | |
| 61 | + // 판보다 위에 칠해진다. 마름모의 안쪽 절반이 판의 테두리를 덮어야 하는데, `--top`에서는 | |
| 62 | + // 꼬리가 DOM에서 먼저 와 판이 나중에 덧칠되기 때문이다. | |
| 63 | + #{$root}__arrow-area { | |
| 64 | + position: relative; | |
| 65 | + z-index: 1; | |
| 66 | + box-sizing: border-box; | |
| 67 | + display: flex; | |
| 68 | + flex-direction: column; | |
| 69 | + align-items: flex-start; | |
| 70 | + padding-inline: fox.padding(8); | |
| 71 | + } | |
| 72 | + | |
| 73 | + // 자리만 잡는다 — 그림은 아래 `::before`가 그린다(파일 상단 주석 참고). | |
| 74 | + #{$root}__arrow { | |
| 75 | + position: relative; | |
| 76 | + display: block; | |
| 77 | + inline-size: 2.2rem; | |
| 78 | + block-size: 1.2rem; | |
| 79 | + } | |
| 80 | + | |
| 81 | + #{$root}__arrow::before { | |
| 82 | + content: ""; | |
| 83 | + position: absolute; | |
| 84 | + inset-inline-start: 50%; | |
| 85 | + // 밑변이 22px이 되는 정사각형의 한 변: 22 ÷ √2. | |
| 86 | + inline-size: 1.5556rem; | |
| 87 | + block-size: 1.5556rem; | |
| 88 | + border-style: solid; | |
| 89 | + border-color: $border; | |
| 90 | + border-width: 0; | |
| 91 | + background: $fill; | |
| 92 | + } | |
| 93 | + | |
| 94 | + // 정사각형의 가운데를 판과 맞닿는 변에 두고, 바깥을 향하는 두 변에만 테두리를 준다. | |
| 95 | + // 45° 돌리면 원래의 위·왼쪽 변이 위쪽 두 빗변이 되고, 아래·오른쪽 변이 아래쪽 두 빗변이 된다. | |
| 96 | + #{$root}--top #{$root}__arrow::before { | |
| 97 | + inset-block-start: 100%; | |
| 98 | + border-block-start-width: fox.border(1); | |
| 99 | + border-inline-start-width: fox.border(1); | |
| 100 | + transform: translate(-50%, -50%) scaleY(calc(12 / 11)) rotate(45deg); | |
| 101 | + } | |
| 102 | + | |
| 103 | + #{$root}--bottom { | |
| 104 | + justify-content: flex-end; | |
| 105 | + } | |
| 106 | + | |
| 107 | + #{$root}--bottom #{$root}__arrow::before { | |
| 108 | + inset-block-start: 0; | |
| 109 | + border-block-end-width: fox.border(1); | |
| 110 | + border-inline-end-width: fox.border(1); | |
| 111 | + transform: translate(-50%, -50%) scaleY(calc(12 / 11)) rotate(45deg); | |
| 112 | + } | |
| 113 | + | |
| 114 | + // 꼬리의 가로 위치는 루트의 정렬이 정한다(시안 지정). 판은 `align-self: stretch`라 이 값에 | |
| 115 | + // 영향받지 않고 언제나 폭을 꽉 채운다 — 움직이는 것은 꼬리뿐이다. | |
| 116 | + #{$root}--arrow-left { | |
| 117 | + align-items: flex-start; | |
| 118 | + } | |
| 119 | + | |
| 120 | + #{$root}--arrow-center { | |
| 121 | + align-items: center; | |
| 122 | + } | |
| 123 | + | |
| 124 | + #{$root}--arrow-right { | |
| 125 | + align-items: flex-end; | |
| 126 | + } | |
| 127 | +} |
+++ @fox/styles/_fox-contextual-trigger.scss
... | ... | @@ -0,0 +1,75 @@ |
| 1 | +// FoxContextualTrigger — 시안: 통합관리자페이지 디자인시스템 Figma contextual-trigger | |
| 2 | +// | |
| 3 | +// 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추다. 계열이 아이콘을 정하고(help는 물음표, | |
| 4 | +// information은 느낌표), 상태는 배경만 바꾼다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <button type="button" class="fox-contextual-trigger fox-contextual-trigger--help" | |
| 8 | +// aria-label="도움말"> | |
| 9 | +// <span class="fox-contextual-trigger__icon">…아이콘 svg…</span> | |
| 10 | +// </button> | |
| 11 | +// | |
| 12 | +// 루트가 `<button>`이라 hover·pressed·포커스·키보드가 전부 네이티브다. 시안의 pressed는 | |
| 13 | +// `:active`가 받는다. | |
| 14 | +// | |
| 15 | +// `fox-icon-button`과 다른 조각이다. 저쪽은 폼 컨트롤과 높이를 맞추는 단추라 크기가 | |
| 16 | +// `form(height-*)`을 따르는데(가장 작은 xsm도 32px), 이쪽은 글줄 옆에 붙는 24px 고정이고 | |
| 17 | +// 모서리도 `radius-2`(4px)다. | |
| 18 | +// | |
| 19 | +// 배경은 세 상태 모두 `action-secondary` 계열이다 — 기본이 투명이라 아래 배경이 그대로 | |
| 20 | +// 비치고, hover·pressed에서만 연한 판이 깔린다(`fox-tag`와 같은 규칙). | |
| 21 | +// | |
| 22 | +// ⚠️ 비활성은 시안에 없다. 색을 임의로 만들지 않고 hover·pressed만 막는다 — 네이티브 | |
| 23 | +// `disabled`를 주면 커서와 클릭만 죽고 모양은 기본과 같다(`fox-tag`와 같은 처리). | |
| 24 | +// | |
| 25 | +// 토큰이 아닌 값: | |
| 26 | +// - box-sizing / display / flex / aspect-ratio / cursor: 구조·상호작용 규칙. | |
| 27 | + | |
| 28 | +@use "@fox/styles/abstracts" as fox; | |
| 29 | + | |
| 30 | +.fox-contextual-trigger { | |
| 31 | + box-sizing: border-box; | |
| 32 | + display: flex; | |
| 33 | + align-items: center; | |
| 34 | + justify-content: center; | |
| 35 | + inline-size: fox.number(10); | |
| 36 | + block-size: fox.number(10); | |
| 37 | + aspect-ratio: 1 / 1; | |
| 38 | + padding: 0; | |
| 39 | + border: none; | |
| 40 | + border-radius: fox.radius(2); | |
| 41 | + background: fox.color(action-secondary); | |
| 42 | + color: fox.color(icon-neutral-default); | |
| 43 | + cursor: pointer; | |
| 44 | + | |
| 45 | + &:disabled { | |
| 46 | + cursor: default; | |
| 47 | + } | |
| 48 | + | |
| 49 | + // 아이콘은 판(24px)보다 작은 20px이다 — 판이 아이콘을 감싸는 여백을 갖는다. | |
| 50 | + &__icon { | |
| 51 | + display: inline-flex; | |
| 52 | + flex-shrink: 0; | |
| 53 | + align-items: center; | |
| 54 | + justify-content: center; | |
| 55 | + inline-size: fox.icon(4); | |
| 56 | + block-size: fox.icon(4); | |
| 57 | + aspect-ratio: 1 / 1; | |
| 58 | + } | |
| 59 | + | |
| 60 | + // 넘긴 SVG가 슬롯을 꽉 채운다 — 크기는 슬롯이 정하고 SVG는 따라온다. | |
| 61 | + &__icon > * { | |
| 62 | + inline-size: 100%; | |
| 63 | + block-size: 100%; | |
| 64 | + } | |
| 65 | + | |
| 66 | + // ── 상태 ──────────────────────────────────────────────────────────────────── | |
| 67 | + // 계열과 무관하게 배경만 바뀐다. | |
| 68 | + &:hover:not(:disabled) { | |
| 69 | + background: fox.color(action-secondary-hover); | |
| 70 | + } | |
| 71 | + | |
| 72 | + &:active:not(:disabled) { | |
| 73 | + background: fox.color(action-secondary-pressed); | |
| 74 | + } | |
| 75 | +} |
+++ @fox/styles/_fox-popover.scss
... | ... | @@ -0,0 +1,195 @@ |
| 1 | +// FoxPopover — 시안: 통합관리자페이지 디자인시스템 Figma popover | |
| 2 | +// | |
| 3 | +// 꼬리가 달린 말풍선 판이다. 제목·본문·글자 버튼을 담고 오른쪽 위에 닫기가 붙는다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <div class="fox-popover fox-popover--top fox-popover--arrow-left"> | |
| 7 | +// <div class="fox-popover__arrow-area"> <!-- top이면 판 위, bottom이면 아래 --> | |
| 8 | +// <span class="fox-popover__arrow"></span> <!-- 비어 있다. 그림은 CSS가 그린다 --> | |
| 9 | +// </div> | |
| 10 | +// <div class="fox-popover__content"> | |
| 11 | +// <div class="fox-popover__body"> | |
| 12 | +// <div class="fox-popover__title-row"> | |
| 13 | +// <strong class="fox-popover__title">제목</strong> | |
| 14 | +// </div> | |
| 15 | +// <div class="fox-popover__message-row"> | |
| 16 | +// <span class="fox-popover__message">본문</span> | |
| 17 | +// </div> | |
| 18 | +// </div> | |
| 19 | +// <button class="fox-text-button … fox-popover__action">바로가기</button> <!-- 선택 --> | |
| 20 | +// <button class="fox-popover__close" aria-label="닫기">…X svg…</button> | |
| 21 | +// </div> | |
| 22 | +// </div> | |
| 23 | +// | |
| 24 | +// `--top`은 꼬리가 판 **위**에 붙고(트리거 아래에 뜨는 말풍선), `--bottom`은 아래에 붙는다. | |
| 25 | +// 마크업 순서가 그것을 정한다 — `order`나 `column-reverse`를 쓰지 않는다. | |
| 26 | +// | |
| 27 | +// **자리를 스스로 잡지 않는다.** 트리거 옆 어디에 놓을지는 호출부가 `className`으로 준다. | |
| 28 | +// 자리가 모자랄 때 위아래를 뒤집는 자동 배치도 없다 — 시안이 방향을 값으로 준다. | |
| 29 | +// | |
| 30 | +// ⚠️ 띄우는 방식이 `fox-select-option`과 같다. 감싼 요소 기준의 일반 흐름이라 | |
| 31 | +// `overflow: hidden` 조상 안에서는 잘린다. HTML `popover` 속성으로 top layer에 올리는 방법이 | |
| 32 | +// 있지만 위치를 잡아 주는 CSS Anchor Positioning이 아직 크롬 계열에만 있어 미룬다 — 옮기게 | |
| 33 | +// 되면 셀렉트·칩·툴팁까지 한 번에 옮긴다. | |
| 34 | +// | |
| 35 | +// 꼬리는 **CSS가 그린다**(SVG를 쓰지 않는다). 그림과 방향·위치 규칙은 `_bubble.scss`의 | |
| 36 | +// `arrow()` 믹스인이 갖는다 — `FoxTooltipRich`와 같은 그림이라 한곳에 둔다. 왜 시안 SVG를 | |
| 37 | +// 쓰지 않는지도 그 파일 상단에 적혀 있다. | |
| 38 | +// | |
| 39 | +// 토큰이 아닌 값: | |
| 40 | +// - ⚠️ `inline-size: 36rem`(360px): 시안 값 그대로다. 토큰 스케일에 360이 없다. 폭을 바꿔야 | |
| 41 | +// 하는 자리는 호출부가 `className`으로 덮는다(`fox-snackbar`·`fox-alert`와 같은 처리). | |
| 42 | +// - box-sizing / display / flex / position: 구조 규칙. | |
| 43 | +// | |
| 44 | +// 꼬리의 가로 위치는 **루트의 `align-items`**가 정한다(시안 지정). 꼬리 영역이 좌우 24px | |
| 45 | +// 여백을 달고 다니므로, 왼쪽·오른쪽에서는 그만큼 안쪽에 서고 가운데에서는 여백이 대칭이라 | |
| 46 | +// 정확히 가운데에 선다. | |
| 47 | + | |
| 48 | +@use "@fox/styles/abstracts" as fox; | |
| 49 | +@use "@fox/styles/bubble" as bubble; | |
| 50 | + | |
| 51 | +.fox-popover { | |
| 52 | + @include bubble.root; | |
| 53 | + | |
| 54 | + // 시안의 neutral/lv2 — 가까운 그림자와 먼 그림자 두 겹이다. | |
| 55 | + // | |
| 56 | + // ⚠️ `box-shadow`가 아니라 `drop-shadow`다. box-shadow는 요소의 **사각형 박스**를 따라 | |
| 57 | + // 그려지므로, 꼬리 옆 빈 자리까지 포함한 네모 그림자가 나온다(시안은 Figma 프레임이 곧 | |
| 58 | + // 모양이라 그 차이가 없다). drop-shadow는 **실제로 칠해진 모양**을 따라가므로 판과 꼬리가 | |
| 59 | + // 하나의 말풍선으로 그림자를 진다. | |
| 60 | + // | |
| 61 | + // 겹쳐 쓰면 뒤의 그림자가 앞 그림자까지 다시 흐리게 하지만, 두 겹 다 10% 알파라 눈에 | |
| 62 | + // 띄는 차이는 없다. | |
| 63 | + filter: | |
| 64 | + drop-shadow( | |
| 65 | + #{fox.shadow(location1)} #{fox.shadow(location1)} #{fox.shadow(blur2)} | |
| 66 | + #{fox.primitive(alpha-black10)} | |
| 67 | + ) | |
| 68 | + drop-shadow( | |
| 69 | + #{fox.shadow(location3)} #{fox.shadow(location3)} #{fox.shadow(blur3)} | |
| 70 | + #{fox.primitive(alpha-black10)} | |
| 71 | + ); | |
| 72 | + | |
| 73 | + | |
| 74 | + // ── 판 ────────────────────────────────────────────────────────────────────── | |
| 75 | + &__content { | |
| 76 | + // 닫기 버튼의 위치 기준이다. | |
| 77 | + position: relative; | |
| 78 | + box-sizing: border-box; | |
| 79 | + display: flex; | |
| 80 | + flex-direction: column; | |
| 81 | + align-items: flex-start; | |
| 82 | + gap: fox.gap(4); | |
| 83 | + align-self: stretch; | |
| 84 | + padding: fox.padding(8); | |
| 85 | + border: fox.border(1) solid fox.color(border-neutral-strong); | |
| 86 | + border-radius: fox.radius(5); | |
| 87 | + background: fox.color(surface-neutral-default); | |
| 88 | + } | |
| 89 | + | |
| 90 | + // ── 꼬리 ──────────────────────────────────────────────────────────────────── | |
| 91 | + // 좌우 여백이 꼬리를 판 가장자리에서 띄운다. **늘리지 않는다** — 폭이 내용(22px + 여백 | |
| 92 | + // 48px)만큼이라야 루트의 `align-items`가 이 덩어리를 왼쪽·가운데·오른쪽으로 옮길 수 있다. | |
| 93 | + // | |
| 94 | + // 판보다 **위에 칠해진다**(시안 구조). 꼬리가 판 쪽으로 1px 파고들고, 꼬리 안의 흰 띠가 그 | |
| 95 | + // 자리의 판 테두리를 지워 말풍선 안팎이 하나로 이어진다. 쌓임 순서를 올리지 않으면 | |
| 96 | + // `--top`에서 판이 나중에 칠해져 그 선이 되살아난다. | |
| 97 | + &__arrow-area { | |
| 98 | + position: relative; | |
| 99 | + z-index: 1; | |
| 100 | + box-sizing: border-box; | |
| 101 | + display: flex; | |
| 102 | + flex-direction: column; | |
| 103 | + align-items: flex-start; | |
| 104 | + padding-inline: fox.padding(8); | |
| 105 | + } | |
| 106 | + | |
| 107 | + // ── 글자 ──────────────────────────────────────────────────────────────────── | |
| 108 | + &__body { | |
| 109 | + display: flex; | |
| 110 | + flex-direction: column; | |
| 111 | + align-items: flex-start; | |
| 112 | + align-self: stretch; | |
| 113 | + } | |
| 114 | + | |
| 115 | + // 오른쪽 여백은 닫기 버튼 자리다 — 제목이 그 밑으로 들어가지 않게 비워 둔다. | |
| 116 | + &__title-row { | |
| 117 | + display: flex; | |
| 118 | + align-items: center; | |
| 119 | + gap: fox.gap(5); | |
| 120 | + align-self: stretch; | |
| 121 | + padding-inline-end: fox.padding(9); | |
| 122 | + } | |
| 123 | + | |
| 124 | + &__title { | |
| 125 | + flex: 1 0 0; | |
| 126 | + min-inline-size: 0; | |
| 127 | + color: fox.color(font-neutral-strong); | |
| 128 | + font-family: fox.font-family(title); | |
| 129 | + font-size: fox.font-size(heading-xsm); | |
| 130 | + font-weight: fox.font-weight(bold); | |
| 131 | + line-height: 1.4; | |
| 132 | + letter-spacing: -0.025em; | |
| 133 | + } | |
| 134 | + | |
| 135 | + // 제목과 본문 사이 간격을 본문의 윗여백이 맡는다(시안이 그렇게 준다) — 제목만 있을 때 | |
| 136 | + // 아래로 빈자리가 남지 않는다. | |
| 137 | + &__message-row { | |
| 138 | + display: flex; | |
| 139 | + align-items: flex-start; | |
| 140 | + align-self: stretch; | |
| 141 | + padding-block-start: fox.padding(6); | |
| 142 | + } | |
| 143 | + | |
| 144 | + &__message { | |
| 145 | + flex: 1 0 0; | |
| 146 | + min-inline-size: 0; | |
| 147 | + color: fox.color(font-neutral-default); | |
| 148 | + font-family: fox.font-family(body); | |
| 149 | + font-size: fox.font-size(body-sm); | |
| 150 | + font-weight: fox.font-weight(regular); | |
| 151 | + line-height: 1.5; | |
| 152 | + letter-spacing: -0.025em; | |
| 153 | + } | |
| 154 | + | |
| 155 | + // 글자 버튼은 자기 모양을 그대로 갖는다 — 여기서는 줄어들지만 않게 막는다. | |
| 156 | + &__action { | |
| 157 | + flex-shrink: 0; | |
| 158 | + } | |
| 159 | + | |
| 160 | + // ── 닫기 ──────────────────────────────────────────────────────────────────── | |
| 161 | + // 판 오른쪽 위 모서리에 붙는다. 누르는 자리는 40px 정사각이고 아이콘은 16px이다. | |
| 162 | + // | |
| 163 | + // ⚠️ 시안이 `align-items: flex-end`를 준다 — 가로는 시작(왼쪽), 세로는 끝(아래)이라 | |
| 164 | + // 아이콘이 40px 칸의 왼쪽 아래로 치우친다(`fox-snackbar`의 닫기와 같다). | |
| 165 | + &__close { | |
| 166 | + position: absolute; | |
| 167 | + inset-block-start: 0; | |
| 168 | + inset-inline-end: 0; | |
| 169 | + box-sizing: border-box; | |
| 170 | + display: flex; | |
| 171 | + align-items: flex-end; | |
| 172 | + inline-size: fox.number(14); | |
| 173 | + block-size: fox.number(14); | |
| 174 | + aspect-ratio: 1 / 1; | |
| 175 | + padding: fox.padding(2); | |
| 176 | + border: none; | |
| 177 | + background: none; | |
| 178 | + color: fox.color(icon-neutral-default); | |
| 179 | + cursor: pointer; | |
| 180 | + } | |
| 181 | + | |
| 182 | + &__close > * { | |
| 183 | + flex-shrink: 0; | |
| 184 | + inline-size: fox.icon(3); | |
| 185 | + block-size: fox.icon(3); | |
| 186 | + aspect-ratio: 1 / 1; | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +// 꼬리. 판과 같은 색을 넘긴다 — 꼬리는 판이 뾰족하게 튀어나온 부분이다. | |
| 191 | +@include bubble.arrow( | |
| 192 | + "fox-popover", | |
| 193 | + $border: fox.color(border-neutral-strong), | |
| 194 | + $fill: fox.color(surface-neutral-default) | |
| 195 | +); |
+++ @fox/styles/_fox-tooltip-rich.scss
... | ... | @@ -0,0 +1,120 @@ |
| 1 | +// FoxTooltipRich — 시안: 통합관리자페이지 디자인시스템 Figma tooltip-rich | |
| 2 | +// | |
| 3 | +// 제목과 본문을 담은 말풍선이다. `fox-popover`와 판·꼬리의 구조가 같고 **누를 것이 없다** — | |
| 4 | +// 닫기도 액션 버튼도 없다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <div class="fox-tooltip-rich fox-tooltip-rich--top fox-tooltip-rich--arrow-left"> | |
| 8 | +// <div class="fox-tooltip-rich__arrow-area"> <!-- top이면 판 위, bottom이면 아래 --> | |
| 9 | +// <span class="fox-tooltip-rich__arrow"></span> <!-- 비어 있다. 그림은 CSS가 그린다 --> | |
| 10 | +// </div> | |
| 11 | +// <div class="fox-tooltip-rich__content"> | |
| 12 | +// <div class="fox-tooltip-rich__body"> | |
| 13 | +// <div class="fox-tooltip-rich__title-row"> | |
| 14 | +// <strong class="fox-tooltip-rich__title">제목</strong> | |
| 15 | +// </div> | |
| 16 | +// <div class="fox-tooltip-rich__message-row"> | |
| 17 | +// <span class="fox-tooltip-rich__message">본문</span> | |
| 18 | +// </div> | |
| 19 | +// </div> | |
| 20 | +// </div> | |
| 21 | +// </div> | |
| 22 | +// | |
| 23 | +// `--top`은 꼬리가 판 **위**에 붙고(트리거 아래에 뜨는 말풍선), `--bottom`은 아래에 붙는다. | |
| 24 | +// 마크업 순서가 그것을 정한다 — `order`나 `column-reverse`를 쓰지 않는다. | |
| 25 | +// | |
| 26 | +// 꼬리는 **CSS가 그린다**(SVG를 쓰지 않는다). 그림과 방향·위치 규칙은 `_bubble.scss`의 | |
| 27 | +// `arrow()` 믹스인이 갖는다 — `fox-popover`와 같은 그림이라 한곳에 둔다. 왜 시안 SVG를 | |
| 28 | +// 쓰지 않는지도 그 파일 상단에 적혀 있다. | |
| 29 | +// | |
| 30 | +// `fox-popover`와 다른 점은 셋뿐이다: | |
| 31 | +// 1. 테두리가 한 단계 옅다(`border-neutral-default` ↔ 팝오버의 `border-neutral-strong`). | |
| 32 | +// 꼬리도 같은 값을 받는다. | |
| 33 | +// 2. 제목과 본문 사이가 좁다(`padding-3` 8px ↔ 팝오버의 `padding-6` 16px). | |
| 34 | +// 3. 그림자가 없다 — 시안이 주지 않는다. | |
| 35 | +// | |
| 36 | +// 제목 줄의 오른쪽 여백(`padding-9`)은 팝오버와 같다. 닫기 버튼이 없어 비워 둘 이유가 | |
| 37 | +// 없어 보이지만 시안이 같은 값을 주므로 그대로 둔다 — 두 말풍선의 글줄 시작·끝이 맞는다. | |
| 38 | +// | |
| 39 | +// 토큰이 아닌 값: | |
| 40 | +// - ⚠️ `inline-size: 36rem`(360px): 시안 값 그대로다. 토큰 스케일에 360이 없다. 폭을 바꿔야 | |
| 41 | +// 하는 자리는 호출부가 `className`으로 덮는다(`fox-popover`와 같은 처리). | |
| 42 | +// - line-height / letter-spacing: 시안 텍스트 스타일 heading/xsm·body/sm의 값이나 Figma가 | |
| 43 | +// 변수로 export하지 않는다. -0.375px·-0.325px은 각각 15px·13px 기준이라 `-0.025em`으로 | |
| 44 | +// 적는다(다른 컴포넌트와 같은 근거). | |
| 45 | +// - box-sizing / display / flex: 구조 규칙. | |
| 46 | + | |
| 47 | +@use "@fox/styles/abstracts" as fox; | |
| 48 | +@use "@fox/styles/bubble" as bubble; | |
| 49 | + | |
| 50 | +.fox-tooltip-rich { | |
| 51 | + @include bubble.root; | |
| 52 | + | |
| 53 | + // ── 판 ────────────────────────────────────────────────────────────────────── | |
| 54 | + &__content { | |
| 55 | + box-sizing: border-box; | |
| 56 | + display: flex; | |
| 57 | + flex-direction: column; | |
| 58 | + align-items: flex-start; | |
| 59 | + gap: fox.gap(4); | |
| 60 | + align-self: stretch; | |
| 61 | + padding: fox.padding(8); | |
| 62 | + border: fox.border(1) solid fox.color(border-neutral-default); | |
| 63 | + border-radius: fox.radius(5); | |
| 64 | + background: fox.color(surface-neutral-default); | |
| 65 | + } | |
| 66 | + | |
| 67 | + // ── 글자 ──────────────────────────────────────────────────────────────────── | |
| 68 | + &__body { | |
| 69 | + display: flex; | |
| 70 | + flex-direction: column; | |
| 71 | + align-items: flex-start; | |
| 72 | + align-self: stretch; | |
| 73 | + } | |
| 74 | + | |
| 75 | + &__title-row { | |
| 76 | + display: flex; | |
| 77 | + align-items: center; | |
| 78 | + gap: fox.gap(5); | |
| 79 | + align-self: stretch; | |
| 80 | + padding-inline-end: fox.padding(9); | |
| 81 | + } | |
| 82 | + | |
| 83 | + &__title { | |
| 84 | + flex: 1 0 0; | |
| 85 | + min-inline-size: 0; | |
| 86 | + color: fox.color(font-neutral-strong); | |
| 87 | + font-family: fox.font-family(title); | |
| 88 | + font-size: fox.font-size(heading-xsm); | |
| 89 | + font-weight: fox.font-weight(bold); | |
| 90 | + line-height: 1.4; | |
| 91 | + letter-spacing: -0.025em; | |
| 92 | + } | |
| 93 | + | |
| 94 | + // 제목과 본문 사이 간격을 본문의 윗여백이 맡는다(시안이 그렇게 준다) — 제목만 있을 때 | |
| 95 | + // 아래로 빈자리가 남지 않는다. | |
| 96 | + &__message-row { | |
| 97 | + display: flex; | |
| 98 | + align-items: flex-start; | |
| 99 | + align-self: stretch; | |
| 100 | + padding-block-start: fox.padding(3); | |
| 101 | + } | |
| 102 | + | |
| 103 | + &__message { | |
| 104 | + flex: 1 0 0; | |
| 105 | + min-inline-size: 0; | |
| 106 | + color: fox.color(font-neutral-default); | |
| 107 | + font-family: fox.font-family(body); | |
| 108 | + font-size: fox.font-size(body-sm); | |
| 109 | + font-weight: fox.font-weight(regular); | |
| 110 | + line-height: 1.5; | |
| 111 | + letter-spacing: -0.025em; | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +// 꼬리. 판과 같은 색을 넘긴다 — 꼬리는 판이 뾰족하게 튀어나온 부분이다. | |
| 116 | +@include bubble.arrow( | |
| 117 | + "fox-tooltip-rich", | |
| 118 | + $border: fox.color(border-neutral-default), | |
| 119 | + $fill: fox.color(surface-neutral-default) | |
| 120 | +); |
+++ @fox/styles/_fox-tooltip.scss
... | ... | @@ -0,0 +1,110 @@ |
| 1 | +// FoxTooltip — 시안: 통합관리자페이지 디자인시스템 Figma tooltip | |
| 2 | +// | |
| 3 | +// 어두운 판에 짧은 글 한 줄을 담은 말풍선이다. `fox-tooltip-rich`의 작은 형제로, 제목이 없고 | |
| 4 | +// 꼬리가 언제나 가운데이며 판이 글자만큼만 넓어진다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <div class="fox-tooltip fox-tooltip--top"> | |
| 8 | +// <div class="fox-tooltip__arrow-area"> <!-- top이면 판 위, bottom이면 아래 --> | |
| 9 | +// <span class="fox-tooltip__arrow">…꼬리 svg…</span> | |
| 10 | +// </div> | |
| 11 | +// <div class="fox-tooltip__content">글자</div> | |
| 12 | +// </div> | |
| 13 | +// | |
| 14 | +// `--top`은 꼬리가 판 **위**에 붙고(트리거 아래에 뜨는 말풍선), `--bottom`은 아래에 붙는다. | |
| 15 | +// 마크업 순서가 그것을 정한다 — `order`나 `column-reverse`를 쓰지 않는다. | |
| 16 | +// | |
| 17 | +// 꼬리의 가로 위치를 고르는 수식어가 없다 — 시안이 언제나 가운데에 둔다. 그래서 이 파일은 | |
| 18 | +// `_bubble.scss`의 `arrow()` 믹스인을 쓰지 않는다. 저쪽은 **테두리가 있는 판**의 이음새를 | |
| 19 | +// 지우려고 회전 정사각형을 반쯤 걸치는 방식인데, 이 판은 테두리 없이 한 색이라 그럴 일이 없고 | |
| 20 | +// 시안의 꼬리도 12×6에 끝이 둥근 다른 그림이다. | |
| 21 | +// | |
| 22 | +// 판은 `inline-flex`라 글자만큼만 넓어진다. 다만 꼬리 영역이 좌우 24px 여백을 달고 있어 | |
| 23 | +// **가장 좁을 때가 60px**(12 + 48)이다 — 글자가 그보다 짧으면 판보다 꼬리 영역이 넓어져 | |
| 24 | +// 말풍선 전체 폭을 그 값이 정한다(시안이 그렇게 준다). | |
| 25 | +// | |
| 26 | +// 토큰이 아닌 값: | |
| 27 | +// - ⚠️ 꼬리 12×6px: 시안이 준 그림의 크기이고 토큰 스케일에 없다. | |
| 28 | +// - line-height / letter-spacing: 시안 텍스트 스타일 body/sm의 값이나 Figma가 변수로 | |
| 29 | +// export하지 않는다. -0.325px은 13px 기준이라 `-0.025em`으로 적는다(다른 컴포넌트와 같은 | |
| 30 | +// 근거). | |
| 31 | +// - display / flex: 구조 규칙. | |
| 32 | + | |
| 33 | +@use "@fox/styles/abstracts" as fox; | |
| 34 | + | |
| 35 | +.fox-tooltip { | |
| 36 | + box-sizing: border-box; | |
| 37 | + display: inline-flex; | |
| 38 | + flex-direction: column; | |
| 39 | + align-items: center; | |
| 40 | + // 글자만큼 넓어진다. `inline-flex`만으로는 부족하다 — 툴팁은 대개 트리거 옆에 | |
| 41 | + // `position: absolute`로 떠 있고, 그때 폭은 **기준이 되는 조상의 폭**을 한도로 계산된다. | |
| 42 | + // 트리거가 24px짜리면 툴팁도 그 안에 맞추려 글자를 쪼갠다. `max-content`는 그 한도를 벗어나 | |
| 43 | + // 내용 그대로의 폭을 갖게 한다. | |
| 44 | + // | |
| 45 | + // ⚠️ 글이 길면 그만큼 한 줄로 길어진다. 줄바꿈이 필요한 자리는 호출부가 `className`으로 | |
| 46 | + // `max-inline-size`를 준다 — 애초에 긴 글은 `fox-tooltip-rich`가 맡는다. | |
| 47 | + inline-size: max-content; | |
| 48 | + | |
| 49 | + &__content { | |
| 50 | + box-sizing: border-box; | |
| 51 | + display: flex; | |
| 52 | + align-items: center; | |
| 53 | + justify-content: center; | |
| 54 | + padding: fox.padding(2) fox.padding(5); | |
| 55 | + border-radius: fox.radius(2); | |
| 56 | + background: fox.color(surface-neutral-default-inverse); | |
| 57 | + | |
| 58 | + color: fox.color(font-neutral-strong-inverse); | |
| 59 | + text-align: center; | |
| 60 | + font-family: fox.font-family(body); | |
| 61 | + font-size: fox.font-size(body-sm); | |
| 62 | + font-weight: fox.font-weight(regular); | |
| 63 | + line-height: 1.5; | |
| 64 | + letter-spacing: -0.025em; | |
| 65 | + } | |
| 66 | + | |
| 67 | + &__arrow-area { | |
| 68 | + box-sizing: border-box; | |
| 69 | + display: flex; | |
| 70 | + flex-direction: column; | |
| 71 | + align-items: center; | |
| 72 | + padding-inline: fox.padding(8); | |
| 73 | + } | |
| 74 | + | |
| 75 | + // 꼬리 색은 판과 같은 값이다 — 꼬리는 판이 뾰족하게 튀어나온 부분이다. SVG가 | |
| 76 | + // `fill="currentColor"`라 이 색을 그대로 입는다. | |
| 77 | + &__arrow { | |
| 78 | + display: block; | |
| 79 | + inline-size: 1.2rem; | |
| 80 | + block-size: 0.6rem; | |
| 81 | + color: fox.color(surface-neutral-default-inverse); | |
| 82 | + } | |
| 83 | + | |
| 84 | + &__arrow > svg { | |
| 85 | + display: block; | |
| 86 | + inline-size: 100%; | |
| 87 | + block-size: 100%; | |
| 88 | + } | |
| 89 | + | |
| 90 | + // 꼬리가 판 위에 붙으면 뾰족한 끝이 위를 향해야 한다. 시안 그림은 아래를 향하고 있어 | |
| 91 | + // 뒤집는다 — 같은 그림 하나로 두 방향을 만든다. | |
| 92 | + &--top &__arrow > svg { | |
| 93 | + transform: rotate(180deg); | |
| 94 | + } | |
| 95 | + | |
| 96 | + // 꼬리를 판 쪽으로 0.5px 겹친다. | |
| 97 | + // | |
| 98 | + // ⚠️ 시안에 없는 값이다. 딱 붙여만 두면 소수 배율 화면(윈도우 125% 등)에서 두 요소의 경계가 | |
| 99 | + // 물리 픽셀 중간에 떨어져 **그 사이로 밝은 실선이 비친다.** 판과 꼬리가 같은 색이라 겹쳐도 | |
| 100 | + // 보이는 모양은 그대로이고, 0.5px이면 꼬리 높이(6px)가 눈에 띄게 줄지도 않는다. | |
| 101 | + // (테두리가 있는 `fox-popover`·`fox-tooltip-rich`는 같은 문제를 다른 방식으로 푼다 — | |
| 102 | + // `_bubble.scss` 상단 참고.) | |
| 103 | + &--top &__arrow-area { | |
| 104 | + margin-block-end: -0.05rem; | |
| 105 | + } | |
| 106 | + | |
| 107 | + &--bottom &__arrow-area { | |
| 108 | + margin-block-start: -0.05rem; | |
| 109 | + } | |
| 110 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -17,6 +17,7 @@ |
| 17 | 17 |
@use "fox-chip-area"; |
| 18 | 18 |
@use "fox-chip-select-option"; |
| 19 | 19 |
@use "fox-conditional-option"; |
| 20 |
+@use "fox-contextual-trigger"; |
|
| 20 | 21 |
@use "fox-email"; |
| 21 | 22 |
@use "fox-file-upload"; |
| 22 | 23 |
@use "fox-form-label"; |
... | ... | @@ -24,6 +25,7 @@ |
| 24 | 25 |
@use "fox-helper-text"; |
| 25 | 26 |
@use "fox-input"; |
| 26 | 27 |
@use "fox-phone-number"; |
| 28 |
+@use "fox-popover"; |
|
| 27 | 29 |
@use "fox-radio"; |
| 28 | 30 |
@use "fox-radio-group"; |
| 29 | 31 |
@use "fox-radio-icon"; |
... | ... | @@ -59,6 +61,8 @@ |
| 59 | 61 |
@use "fox-step-indicator-item"; |
| 60 | 62 |
@use "fox-step-indicator"; |
| 61 | 63 |
@use "fox-toast"; |
| 64 |
+@use "fox-tooltip"; |
|
| 65 |
+@use "fox-tooltip-rich"; |
|
| 62 | 66 |
@use "fox-toggle-quantity"; |
| 63 | 67 |
@use "fox-page-header"; |
| 64 | 68 |
@use "fox-modal"; |
--- next.config.ts
+++ next.config.ts
... | ... | @@ -20,6 +20,18 @@ |
| 20 | 20 |
]; |
| 21 | 21 |
|
| 22 | 22 |
const nextConfig: NextConfig = {
|
| 23 |
+ experimental: {
|
|
| 24 |
+ serverActions: {
|
|
| 25 |
+ // 이미지·첨부파일은 Server Action **본문에 실려** 온다(브라우저가 백엔드를 직접 부르지 |
|
| 26 |
+ // 않으므로 — 토큰이 httpOnly 세션 안에만 있다). 기본값 1MB로는 웬만한 이미지가 요청 |
|
| 27 |
+ // 단계에서 413으로 잘리는데, 그러면 **액션 본문이 실행조차 되지 않아** `useActionState`가 |
|
| 28 |
+ // idle 그대로다 — 화면에는 오류도 토스트도 없이 폼만 비워져 "아무 반응 없음"으로 보인다. |
|
| 29 |
+ // 백엔드는 `CommonsMultipartResolver`가 100MB까지 받지만, 이 본문은 Next 서버 메모리에 |
|
| 30 |
+ // 통째로 올라가므로 관리 화면에 필요한 만큼만 연다. |
|
| 31 |
+ bodySizeLimit: '10mb', |
|
| 32 |
+ }, |
|
| 33 |
+ }, |
|
| 34 |
+ |
|
| 23 | 35 |
// SCSS 로드 경로 — 프로젝트 루트를 Sass의 loadPaths에 넣어 `@use "@fox/styles/abstracts"` |
| 24 | 36 |
// 같은 루트 기준 절대 경로가 해석되게 한다. 이게 없으면 컴포넌트마다 `../../../` 상대 |
| 25 | 37 |
// 경로를 써야 해서 @fox 폴더를 다른 프로젝트로 통째로 복사할 수 없다(포터블 요건). |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?