feat: FoxSortableList의 끌기 손잡이를 없애고 항목 전체를 집어 끌게 한다
가맹몰 추가 이미지는 타일 자체를 잡아 좌우로 옮기고 하단 [저장] 한 번에 반영하는 방식이다 (사용자 확정). 왼쪽 위 손잡이 버튼을 지우고 li가 draggable·tabindex=0이 되어 마우스는 타일을, 키보드는 초점을 둔 항목을 방향키로 옮긴다. 본문 안 버튼·입력에서 올라오는 키는 받지 않는다. Co-Authored-By: Claude Opus 5
@bf310a25ed2eab8ada682badfd0626aca1cc8da8
--- @fox/core/components/fox-sortable-list/fox-sortable-list.tsx
+++ @fox/core/components/fox-sortable-list/fox-sortable-list.tsx
... | ... | @@ -15,12 +15,12 @@ |
| 15 | 15 |
items: readonly T[]; |
| 16 | 16 |
/** 항목의 안정적인 키. 인덱스를 쓰면 옮긴 뒤 상태가 어긋난다. */ |
| 17 | 17 |
getKey: (item: T) => string; |
| 18 |
- /** 항목 본문. 핸들은 목록이 그리므로 여기서 다시 그리지 않는다. */ |
|
| 18 |
+ /** 항목 본문. 항목 전체가 끌리므로 본문에 끌기 손잡이를 따로 두지 않는다. */ |
|
| 19 | 19 |
renderItem: (item: T, index: number) => ReactNode; |
| 20 | 20 |
/** 순서가 바뀔 때마다 새 배열을 준다. 목록은 상태를 갖지 않는다. */ |
| 21 | 21 |
onReorder: (next: T[]) => void; |
| 22 | 22 |
direction?: FoxSortableListDirection; |
| 23 |
- /** 목록 전체를 읽어 줄 이름. 핸들의 안내문에도 쓰인다. */ |
|
| 23 |
+ /** 목록 전체를 읽어 줄 이름. 항목의 안내문에도 쓰인다. */ |
|
| 24 | 24 |
ariaLabel: string; |
| 25 | 25 |
disabled?: boolean; |
| 26 | 26 |
className?: string; |
... | ... | @@ -29,8 +29,13 @@ |
| 29 | 29 |
/** |
| 30 | 30 |
* @fox 정렬 목록 — 항목의 **순서만** 바꾼다. 내용은 `renderItem`이 그린다. |
| 31 | 31 |
* |
| 32 |
- * 마우스는 핸들을 끌어 놓고(네이티브 HTML5 드래그 — 의존성 없음), 키보드는 핸들에 초점을 |
|
| 33 |
- * 두고 방향키로 옮긴다. 둘 다 `onReorder`로 새 배열을 돌려주며 목록은 상태를 갖지 않는다. |
|
| 32 |
+ * 마우스는 항목 자체를 잡아 끌어 놓고(네이티브 HTML5 드래그 — 의존성 없음), 키보드는 항목에 |
|
| 33 |
+ * 초점을 두고 방향키로 옮긴다. 둘 다 `onReorder`로 새 배열을 돌려주며 목록은 상태를 갖지 않는다. |
|
| 34 |
+ * 손잡이(핸들)는 두지 않는다 — 타일을 통째로 집는 편이 자연스럽다(사용자 확정). 본문에 글을 |
|
| 35 |
+ * 입력하는 칸이 있는 목록이 생기면 그때 손잡이 변형을 더한다. |
|
| 36 |
+ * |
|
| 37 |
+ * 방향키는 항목 자신에 초점이 있을 때만 받는다 — 본문 안의 버튼·입력에서 올라오는 키는 |
|
| 38 |
+ * 그쪽 몫이다. |
|
| 34 | 39 |
* |
| 35 | 40 |
* 놓는 위치는 "끌고 있는 항목을 지금 가리키는 항목 자리로" 한 가지다 — 앞뒤 절반을 나누면 |
| 36 | 41 |
* 타일 크기가 제각각인 곳에서 예측이 안 된다. |
... | ... | @@ -98,7 +103,10 @@ |
| 98 | 103 |
setOverKey(null); |
| 99 | 104 |
} |
| 100 | 105 |
|
| 101 |
- function handleHandleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
|
| 106 |
+ function handleItemKeyDown(event: KeyboardEvent<HTMLLIElement>, index: number) {
|
|
| 107 |
+ if (event.target !== event.currentTarget) {
|
|
| 108 |
+ return; |
|
| 109 |
+ } |
|
| 102 | 110 |
const backward = direction === "horizontal" ? "ArrowLeft" : "ArrowUp"; |
| 103 | 111 |
const forward = direction === "horizontal" ? "ArrowRight" : "ArrowDown"; |
| 104 | 112 |
|
... | ... | @@ -148,23 +156,16 @@ |
| 148 | 156 |
isDragging && "fox-sortable-list__item--dragging", |
| 149 | 157 |
isOver && "fox-sortable-list__item--over", |
| 150 | 158 |
)} |
| 159 |
+ tabIndex={disabled ? undefined : 0}
|
|
| 160 |
+ aria-label={disabled ? undefined : `${index + 1}번째 항목 — 끌거나 방향키로 순서 변경`}
|
|
| 161 |
+ aria-describedby={disabled ? undefined : hintId}
|
|
| 162 |
+ draggable={!disabled}
|
|
| 163 |
+ onDragStart={disabled ? undefined : (event) => handleDragStart(event, key)}
|
|
| 164 |
+ onDragEnd={disabled ? undefined : handleDragEnd}
|
|
| 151 | 165 |
onDragOver={disabled ? undefined : (event) => handleDragOver(event, key)}
|
| 152 | 166 |
onDrop={disabled ? undefined : (event) => handleDrop(event, key)}
|
| 167 |
+ onKeyDown={disabled ? undefined : (event) => handleItemKeyDown(event, index)}
|
|
| 153 | 168 |
> |
| 154 |
- {!disabled && (
|
|
| 155 |
- <button |
|
| 156 |
- type="button" |
|
| 157 |
- className="fox-sortable-list__handle" |
|
| 158 |
- aria-label={`${index + 1}번째 항목 순서 변경`}
|
|
| 159 |
- aria-describedby={hintId}
|
|
| 160 |
- draggable |
|
| 161 |
- onDragStart={(event) => handleDragStart(event, key)}
|
|
| 162 |
- onDragEnd={handleDragEnd}
|
|
| 163 |
- onKeyDown={(event) => handleHandleKeyDown(event, index)}
|
|
| 164 |
- > |
|
| 165 |
- <i className="fox-ico fox-ico-DotsSixVertical" aria-hidden="true" /> |
|
| 166 |
- </button> |
|
| 167 |
- )} |
|
| 168 | 169 |
<div className="fox-sortable-list__body">{renderItem(item, index)}</div>
|
| 169 | 170 |
</li> |
| 170 | 171 |
); |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -4341,10 +4341,10 @@ |
| 4341 | 4341 |
id: "fox-sortable-list", |
| 4342 | 4342 |
name: "FoxSortableList", |
| 4343 | 4343 |
description: |
| 4344 |
- "항목 순서를 끌어서 바꾸는 목록입니다. 시안에 정렬 목록 컴포넌트가 없어 기획 문구(추가 이미지는 드래그로 순서를 바꾼다)만으로 만들었습니다 — 핸들 하나와 끌고 있는 항목·놓을 자리 표시가 전부이고 본문은 호출부가 그립니다. 핸들에 초점을 두고 방향키(가로는 ←→, 세로는 ↑↓)·Home·End로도 옮길 수 있어 마우스 없이도 됩니다. 목록은 상태를 갖지 않고 onReorder로 새 배열만 돌려줍니다.", |
|
| 4344 |
+ "항목 순서를 끌어서 바꾸는 목록입니다. 시안에 정렬 목록 컴포넌트가 없어 기획 문구(추가 이미지는 드래그로 순서를 바꾼다)만으로 만들었습니다 — 손잡이 없이 항목 자체를 집어 끌고, 끌고 있는 항목·놓을 자리 표시가 전부이며 본문은 호출부가 그립니다. 항목에 초점을 두고 방향키(가로는 ←→, 세로는 ↑↓)·Home·End로도 옮길 수 있어 마우스 없이도 됩니다. 목록은 상태를 갖지 않고 onReorder로 새 배열만 돌려줍니다.", |
|
| 4345 | 4345 |
variants: [ |
| 4346 | 4346 |
{
|
| 4347 |
- label: "가로 — 핸들을 끌거나 초점을 두고 ←→", |
|
| 4347 |
+ label: "가로 — 타일을 끌거나 Tab으로 초점을 두고 ←→", |
|
| 4348 | 4348 |
node: <SortableListLiveDemo direction="horizontal" />, |
| 4349 | 4349 |
}, |
| 4350 | 4350 |
{
|
--- @fox/styles/_fox-sortable-list.scss
+++ @fox/styles/_fox-sortable-list.scss
... | ... | @@ -1,22 +1,19 @@ |
| 1 | 1 |
// FoxSortableList — 항목 순서를 끌어서(또는 방향키로) 바꾸는 목록. |
| 2 | 2 |
// |
| 3 | 3 |
// 시안에 정렬 목록 컴포넌트가 없어 기획 문구("추가 이미지는 드래그로 순서를 바꾼다",
|
| 4 |
-// BO-MAL-002 ⑤)만으로 만들었다(사용자 확정). 모양은 최소한으로 둔다 — 핸들 하나와 |
|
| 5 |
-// 끌고 있는 항목·놓을 자리 표시뿐이고, 항목 본문은 호출부가 그린다. |
|
| 4 |
+// BO-MAL-002 ⑤)만으로 만들었다(사용자 확정). 모양은 최소한으로 둔다 — 끌고 있는 항목·놓을 |
|
| 5 |
+// 자리 표시뿐이고, 항목 본문은 호출부가 그린다. 손잡이 없이 항목 전체를 집는다(사용자 확정). |
|
| 6 | 6 |
// |
| 7 | 7 |
// 마크업 계약 (React 밖 소비자용): |
| 8 | 8 |
// <ul class="fox-sortable-list fox-sortable-list--horizontal" aria-label="…"> |
| 9 |
-// <li class="fox-sortable-list__item [--dragging|--over]"> |
|
| 10 |
-// <button class="fox-sortable-list__handle" type="button" draggable aria-label="…"> |
|
| 11 |
-// <i class="fox-ico fox-ico-DotsSixVertical"></i> |
|
| 12 |
-// </button> |
|
| 9 |
+// <li class="fox-sortable-list__item [--dragging|--over]" tabindex="0" draggable aria-label="…"> |
|
| 13 | 10 |
// <div class="fox-sortable-list__body">…항목 본문…</div> |
| 14 | 11 |
// </li> |
| 15 | 12 |
// </ul> |
| 16 | 13 |
// |
| 17 | 14 |
// 토큰이 아닌 값: |
| 18 | 15 |
// - 끌고 있는 항목의 opacity: 상태 표현이라 색 토큰이 없다. 관례값 0.4. |
| 19 |
-// - 버튼 초기화(appearance/cursor/색 상속): 구조 규칙. |
|
| 16 |
+// - cursor grab/grabbing: 구조 규칙. |
|
| 20 | 17 |
|
| 21 | 18 |
@use "abstracts" as fox; |
| 22 | 19 |
|
... | ... | @@ -39,11 +36,20 @@ |
| 39 | 36 |
position: relative; |
| 40 | 37 |
display: flex; |
| 41 | 38 |
align-items: flex-start; |
| 42 |
- gap: fox.gap(2); |
|
| 43 | 39 |
border-radius: fox.radius(3); |
| 44 | 40 |
// 놓을 자리 테두리가 나타날 때 항목이 밀리지 않도록 투명 테두리를 미리 둔다. |
| 45 | 41 |
outline: fox.border(2) solid transparent; |
| 46 | 42 |
outline-offset: fox.number(2); |
| 43 |
+ cursor: grab; |
|
| 44 |
+ |
|
| 45 |
+ &:active {
|
|
| 46 |
+ cursor: grabbing; |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ // 키보드로 잡았을 때 — 초점 테두리가 놓을 자리 표시와 같은 자리를 쓴다. |
|
| 50 |
+ &:focus-visible {
|
|
| 51 |
+ outline-color: fox.color(border-theme-primary); |
|
| 52 |
+ } |
|
| 47 | 53 |
|
| 48 | 54 |
&--dragging {
|
| 49 | 55 |
opacity: 0.4; |
... | ... | @@ -54,31 +60,6 @@ |
| 54 | 60 |
} |
| 55 | 61 |
} |
| 56 | 62 |
|
| 57 |
- &__handle {
|
|
| 58 |
- display: inline-flex; |
|
| 59 |
- flex-shrink: 0; |
|
| 60 |
- align-items: center; |
|
| 61 |
- justify-content: center; |
|
| 62 |
- padding: fox.padding(1); |
|
| 63 |
- margin: 0; |
|
| 64 |
- border: 0; |
|
| 65 |
- border-radius: fox.radius(2); |
|
| 66 |
- background: transparent; |
|
| 67 |
- color: fox.color(icon-neutral-subtle); |
|
| 68 |
- font-size: fox.icon(3); |
|
| 69 |
- cursor: grab; |
|
| 70 |
- appearance: none; |
|
| 71 |
- |
|
| 72 |
- &:hover {
|
|
| 73 |
- background: fox.color(action-default-hover); |
|
| 74 |
- color: fox.color(icon-neutral-default); |
|
| 75 |
- } |
|
| 76 |
- |
|
| 77 |
- &:active {
|
|
| 78 |
- cursor: grabbing; |
|
| 79 |
- } |
|
| 80 |
- } |
|
| 81 |
- |
|
| 82 | 63 |
&__body {
|
| 83 | 64 |
min-inline-size: 0; |
| 84 | 65 |
flex: 1 1 auto; |
... | ... | @@ -86,5 +67,6 @@ |
| 86 | 67 |
|
| 87 | 68 |
&--disabled &__item {
|
| 88 | 69 |
outline: 0; |
| 70 |
+ cursor: default; |
|
| 89 | 71 |
} |
| 90 | 72 |
} |
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?