@ihabdevteam/core 0.255.0 → 0.257.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.
- package/CHANGELOG.md +171 -0
- package/dist/components.cjs.js +175 -175
- package/dist/components.esm.css +4 -4
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +183 -183
- package/dist/ihabdevteam-core.d.ts +66 -8
- package/dist/packages/core/src/components/Button.js +1 -1
- package/dist/packages/core/src/components/Calendar.css +6 -0
- package/dist/packages/core/src/components/Calendar.js +23 -1
- package/dist/packages/core/src/components/Panel.css +25 -3
- package/dist/packages/core/src/components/Panel.d.ts +10 -2
- package/dist/packages/core/src/components/Panel.js +21 -3
- package/dist/packages/core/src/components/Popup/Popup.css +10 -6
- package/dist/packages/core/src/components/Section.d.ts +16 -3
- package/dist/packages/core/src/components/Section.js +1 -1
- package/dist/packages/core/src/components/Table.d.ts +16 -0
- package/dist/packages/core/src/components/Table.js +17 -5
- package/dist/packages/core/src/components/TableCell.css +29 -0
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +28 -8
- package/dist/packages/core/src/components/Training/TrainingCard.d.ts +15 -1
- package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.d.ts +8 -1
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +10 -2
- package/dist/packages/core/src/components/WordTable.css +19 -9
- package/dist/packages/core/src/components/buttonTypes.d.ts +1 -1
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +8 -4
- package/dist/packages/core/src/i18n/locales/en.json +5 -1
- package/dist/packages/core/src/i18n/locales/ko.json +5 -1
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -844,7 +844,7 @@ export declare interface ButtonProps {
|
|
|
844
844
|
type?: ButtonLayoutType;
|
|
845
845
|
/**
|
|
846
846
|
* DOM 의 button type 속성. `type` 은 레이아웃 종류라 이름이 겹쳐 따로 둔다.
|
|
847
|
-
*
|
|
847
|
+
* 기본값은 'button'. 폼을 제출하려면 'submit', 초기화하려면 'reset'을 명시한다.
|
|
848
848
|
*/
|
|
849
849
|
htmlType?: 'button' | 'submit' | 'reset';
|
|
850
850
|
/**
|
|
@@ -1651,7 +1651,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1651
1651
|
*
|
|
1652
1652
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1653
1653
|
*/
|
|
1654
|
-
export declare const CORE_VERSION = "0.
|
|
1654
|
+
export declare const CORE_VERSION = "0.257.0";
|
|
1655
1655
|
|
|
1656
1656
|
/** @public */
|
|
1657
1657
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -4204,8 +4204,16 @@ export declare type PanelElement = HTMLDivElement & {
|
|
|
4204
4204
|
};
|
|
4205
4205
|
|
|
4206
4206
|
/** @public */
|
|
4207
|
-
export declare function PanelGrid({ cols, children }: {
|
|
4207
|
+
export declare function PanelGrid({ cols, layout, gap, collapseAt, className, style, children, }: {
|
|
4208
4208
|
cols?: number;
|
|
4209
|
+
/** grid: 기존 행·셀 배치. columns: 콘텐츠 높이를 유지하며 위에서 아래로 배치 */
|
|
4210
|
+
layout?: 'grid' | 'columns';
|
|
4211
|
+
/** 숫자는 px, 문자열은 CSS 길이 또는 디자인 토큰. 기본 1px */
|
|
4212
|
+
gap?: number | string;
|
|
4213
|
+
/** 컨테이너 너비가 이 값(px) 이하이면 1열. 생략하면 열 개수 유지 */
|
|
4214
|
+
collapseAt?: number;
|
|
4215
|
+
className?: string;
|
|
4216
|
+
style?: CSSProperties;
|
|
4209
4217
|
children: ReactNode;
|
|
4210
4218
|
}): JSX.Element;
|
|
4211
4219
|
|
|
@@ -6409,11 +6417,24 @@ export declare interface SectionProps {
|
|
|
6409
6417
|
type?: SectionType;
|
|
6410
6418
|
title?: ReactNode;
|
|
6411
6419
|
/**
|
|
6412
|
-
* 제목을 이 단계의 제목 태그(`h1`~`h6`)로
|
|
6420
|
+
* 제목을 이 단계의 제목 태그(`h1`~`h6`)로 그린다. **기본 2** — 페이지 제목(`h1`) 아래 구획을
|
|
6421
|
+
* 가리킨다. 그 아래로 들어가면 3·4 로 내린다. 글자 모양은 어느 쪽이든 `type` 이 정한다.
|
|
6422
|
+
*
|
|
6423
|
+
* **전에는 기본이 `div` 였고 "주는 것을 권한다" 고만 적어 두었다**(2026-10-02 바꿈).
|
|
6424
|
+
* 그 권고를 **아무도 안 따랐다** — 말귀 앱 쉰일곱 자리 중 주는 자리가 **0**, 그리고
|
|
6425
|
+
* **코어 제 안의 열네 자리도 0** 이었다. 그래서 칸 다섯이 선 화면에 머리글이 `h1` 하나뿐인
|
|
6426
|
+
* 꼴이 났다. 형제 다섯(`ChartSection` 3 · `Panel` 2 · `FilterPanel` 2 · `TrainingCard` 3 ·
|
|
6427
|
+
* `TrainingCardGroup` 3)은 전부 **안 줘도 머리글이 서는데** 이것만 달랐다 —
|
|
6428
|
+
* 같은 물음에 한 집안이 두 가지로 답하고 있었다.
|
|
6429
|
+
*
|
|
6430
|
+
* **값을 치른다**: 급을 안 주던 자리가 한꺼번에 `h2` 가 된다. 깊이 든 구획이면 층이 한 급
|
|
6431
|
+
* 건너뛴 것처럼 읽힐 수 있다 — 그 자리는 숫자를 적어 고친다. **급이 틀린 것은 머리글이
|
|
6432
|
+
* 아예 없는 것보다 가볍다**: 틀려도 건너뛸 자리는 생기고, 없으면 길이 없다.
|
|
6413
6433
|
*
|
|
6414
|
-
*
|
|
6434
|
+
* `0` 을 주면 전과 같이 `div` 로 그린다(급을 부르는 쪽이 못 정하는 자리용 —
|
|
6435
|
+
* `ChartSection.titleLevel` 과 같은 탈출구).
|
|
6415
6436
|
*/
|
|
6416
|
-
headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
6437
|
+
headingLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
6417
6438
|
icon?: string;
|
|
6418
6439
|
action?: ReactNode;
|
|
6419
6440
|
disc?: ReactNode;
|
|
@@ -7428,10 +7449,26 @@ export declare type TableProps<T extends {
|
|
|
7428
7449
|
*/
|
|
7429
7450
|
headerGroups?: TableHeaderGroup[];
|
|
7430
7451
|
items?: T[];
|
|
7452
|
+
/**
|
|
7453
|
+
* 줄 하나를 칸 배열로 그린다.
|
|
7454
|
+
*
|
|
7455
|
+
* **`rowLabel` 을 준 칸은 그 안이 진짜 `<button>` 이 된다**(말귀 요청 (56)(나), 2026-10-02).
|
|
7456
|
+
* (가) 에서 줄이 `role="button"` 을 잃으면서 **"눌릴 수 있다" 는 말**이 사라졌고,
|
|
7457
|
+
* `aria-describedby` 로 메워 뒀다. 그 자리를 제대로 메우는 길이 이것이다 — 줄 안에
|
|
7458
|
+
* **이름 있는 진짜 단추**를 세운다.
|
|
7459
|
+
*
|
|
7460
|
+
* 왜 코어가 못 정하고 부르는 쪽이 짚어야 하나: **단어 관리의 첫 칸은 체크박스**다.
|
|
7461
|
+
* 첫 칸을 단추로 감싸면 단추 안에 조작이 또 들어간다(오늘 (65)·0.254.0 에서 걷은 그 꼴).
|
|
7462
|
+
* 그리고 코어는 `content` 가 사람 이름인지 날짜인지 모른다. 그래서 **어느 칸이 줄을
|
|
7463
|
+
* 가리키는 이름인지**를 부르는 쪽이 적는다.
|
|
7464
|
+
*
|
|
7465
|
+
* 안 주면 전과 같다 — 줄이 `tabIndex` 와 설명으로만 눌린다((가) 그대로).
|
|
7466
|
+
*/
|
|
7431
7467
|
renderRow: (item: T) => Array<{
|
|
7432
7468
|
content: ReactNode;
|
|
7433
7469
|
className?: string;
|
|
7434
7470
|
style?: CSSProperties;
|
|
7471
|
+
rowLabel?: string;
|
|
7435
7472
|
}>;
|
|
7436
7473
|
onRowClick?: (item: T) => void;
|
|
7437
7474
|
/**
|
|
@@ -8323,7 +8360,7 @@ export declare interface TrainingBlockProps {
|
|
|
8323
8360
|
}
|
|
8324
8361
|
|
|
8325
8362
|
/** @public */
|
|
8326
|
-
export declare function TrainingCard({ title, titleLevel, description, topActions, children, footerLeft, footerRight, onClick, draggable, onDragStart, onDragEnd, className, }: TrainingCardProps): JSX.Element;
|
|
8363
|
+
export declare function TrainingCard({ title, titleLevel, description, topActions, children, footerLeft, footerRight, ariaLabel, onClick, draggable, onDragStart, onDragEnd, className, }: TrainingCardProps): JSX.Element;
|
|
8327
8364
|
|
|
8328
8365
|
/** @public */
|
|
8329
8366
|
export declare function TrainingCardGroup({ title, titleLevel, count, collapsed, onToggle, sort, actions, empty, emptyContent, dragOver, onDragOver, onDragLeave, onDrop, children, className, }: TrainingCardGroupProps): JSX.Element;
|
|
@@ -8337,7 +8374,14 @@ declare type TrainingCardGroupProps = {
|
|
|
8337
8374
|
* 안 잡혔다. 그 안의 `접기`·`정렬` 은 그룹마다 같은 이름이라, 낭독기로 단추 목록을
|
|
8338
8375
|
* 부르면 `접기` 가 세 번 선다 — **어느 그룹의 것인지 가릴 길이 없었다.**
|
|
8339
8376
|
*
|
|
8340
|
-
* 이름 있는 지역이 서면 단추 이름은 짧은 채로 둬도 된다(지역이 가린다)
|
|
8377
|
+
* **전에 여기 "이름 있는 지역이 서면 단추 이름은 짧은 채로 둬도 된다(지역이 가린다)" 고
|
|
8378
|
+
* 적어 두었는데, 그 말이 틀렸다**(2026-10-02 바로잡음). `role="group"` 은 **landmark 가
|
|
8379
|
+
* 아니다.** 초점이 묶음 안으로 들어가 읽을 때는 묶음 이름이 먼저 읽히니 맞지만,
|
|
8380
|
+
* **낭독기로 단추 목록을 훑는 길**에서는 묶음 맥락이 안 따라온다 — 바로 윗줄에 적은
|
|
8381
|
+
* "`접기` 가 세 번 선다" 가 그대로 남는다. 말귀가 그 한 줄을 믿고 앱 세 파일에서 그룹
|
|
8382
|
+
* 이름을 걷으려다 물어 와서 드러났다.
|
|
8383
|
+
* 그래서 **이 조각이 제 단추에 묶음 이름을 직접 단다**(아래 `접기`·`정렬`). 부르는 쪽이
|
|
8384
|
+
* actions 로 넘기는 단추에도 묶음 이름을 다는 쪽이 맞다 — 걷지 마라.
|
|
8341
8385
|
* `0` 을 주면 전과 같이 `span` 으로 그린다. */
|
|
8342
8386
|
titleLevel?: 0 | 2 | 3 | 4 | 5 | 6;
|
|
8343
8387
|
/** 그룹 제목 — 문자열이면 제목으로 감싸고, 노드면 그대로 렌더(예: 인라인 이름변경 입력) */
|
|
@@ -8411,6 +8455,20 @@ export declare type TrainingCardProps = {
|
|
|
8411
8455
|
description?: ReactNode;
|
|
8412
8456
|
/** 우측 상단 액션 영역 (좋아요/다운로드, ⋯ 메뉴 등) — 클릭이 카드로 전파되지 않음 */
|
|
8413
8457
|
topActions?: ReactNode;
|
|
8458
|
+
/**
|
|
8459
|
+
* **카드가 눌릴 때 읽히는 이름**(말귀 요청 (65), 2026-10-02).
|
|
8460
|
+
*
|
|
8461
|
+
* 안 주면 `title` 을 쓴다. 안 쓰고 두면 `role="button"` 의 이름이 **안 글자 전부**로
|
|
8462
|
+
* 지어지는데, 이 카드는 안에 `topActions`(진짜 `<button>`)를 품으므로 **그 단추의 이름까지
|
|
8463
|
+
* 삼킨다** — 말귀 실측(나의 단어훈련 375px, 카드 여섯 장 전부):
|
|
8464
|
+
* `훈련하자!! (복사) 훈련하자!! (복사) 더보기 음절 2개 합치기 1 …` 로, 제목이 두 번 읽히고
|
|
8465
|
+
* 사이에 `더보기` 가 꼈다. `TrainingBlockButton` 이 이미 같은 길로 풀어 둔 자리다.
|
|
8466
|
+
*
|
|
8467
|
+
* **값을 치른다** — 갈래별 수와 마지막 쓴 날이 이름에서 빠진다. 그것까지 들려주려면
|
|
8468
|
+
* 부르는 쪽이 여기에 **갖춰 쓴 이름**을 넘겨라(`'훈련하자!! · 음절 합치기 1 · 3개월 전'`).
|
|
8469
|
+
* 코어는 children 의 뜻을 모르므로 제목까지만 지을 수 있다.
|
|
8470
|
+
*/
|
|
8471
|
+
ariaLabel?: string;
|
|
8414
8472
|
/** 본문 영역 콘텐츠 (타입 칩, 단어 뱃지, 빈 상태 등) */
|
|
8415
8473
|
children?: ReactNode;
|
|
8416
8474
|
/** 풋터 좌측 (게시자·날짜 등) */
|
|
@@ -10,7 +10,7 @@ import { isTouchPointer, recentlyTouched, useTouchToggle } from '../hooks/touchT
|
|
|
10
10
|
/* **ref 는 forwardRef 로 받는다** — React 19 는 ref 를 props 로 넘기지만 peer 는 16.8~ 라, React 18(kscan) 에서는 함수 컴포넌트에 준
|
|
11
11
|
ref 가 버려진다. 게다가 전에는 안쪽 ref(`btnRef`)가 props 에서 온 ref 를 덮어써 19 에서도 부르는 쪽 ref 가 비었다. 두 ref 를 잇는다. */
|
|
12
12
|
function ButtonInner(props, forwardedRef) {
|
|
13
|
-
const { size = 48, type: explicitType, htmlType, href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', nowrap = false, selected, toggleable = false, ariaExpanded, ariaControls, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, tooltipOnly = false, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
|
|
13
|
+
const { size = 48, type: explicitType, htmlType = 'button', href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', nowrap = false, selected, toggleable = false, ariaExpanded, ariaControls, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, tooltipOnly = false, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
|
|
14
14
|
const sizeKey = typeof size === 'string' && isNaN(Number(size))
|
|
15
15
|
? size.toLowerCase()
|
|
16
16
|
: String(Number(size) || 48);
|
|
@@ -147,6 +147,12 @@
|
|
|
147
147
|
background: currentColor;
|
|
148
148
|
flex-shrink: 0;
|
|
149
149
|
}
|
|
150
|
+
/* **범례와 같은 세 색**((66), 2026-10-02). 전에는 `currentColor` 하나라 예약·미방문도
|
|
151
|
+
'방문' 색으로 섰다 — 머리 바로 위 범례는 셋이라고 적어 둔 채로. 범례의
|
|
152
|
+
`--main`·`--sub`·`--disabled` 와 **같은 값을 쓴다**: 둘이 갈리면 범례가 거짓말이 된다. */
|
|
153
|
+
.calendar-cell__visit-dot--visited { background: var(--color-main); }
|
|
154
|
+
.calendar-cell__visit-dot--scheduled { background: var(--color-sub); }
|
|
155
|
+
.calendar-cell__visit-dot--noVisit { background: var(--color-disabled); }
|
|
150
156
|
/* 이름표 한 줄("13시 33분 · 지한")이 담기려면 100px 은 있어야 한다 — 그 밑에서는 접는다.
|
|
151
157
|
창이 아니라 **칸 제 폭**으로 정한다: 같은 창에서도 담긴 카드가 좁으면 칸이 좁아진다.
|
|
152
158
|
재는 값은 칸의 **안쪽**(여백 8px씩 뺀 값)이라, 118px 짜리 칸이면 102 로 이 문턱을 넘는다. */
|
|
@@ -85,7 +85,29 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
|
|
|
85
85
|
dragVisitId.current = null;
|
|
86
86
|
}
|
|
87
87
|
setDragOverDate(null);
|
|
88
|
-
} : undefined, children: [_jsx("div", { className: "calendar-cell__number body-3", children: day }), dayVisits.length > 0 && (
|
|
88
|
+
} : undefined, children: [_jsx("div", { className: "calendar-cell__number body-3", children: day }), dayVisits.length > 0 && (() => {
|
|
89
|
+
/* **접힌 꼴이 세 갈래를 하나로 뭉갰다**(말귀 요청 (66), 2026-10-02).
|
|
90
|
+
점은 늘 `--color-main`(= 범례의 '방문') 이고 이름표는 늘 `방문 N건` 이라,
|
|
91
|
+
**아직 안 온 예약과 안 온 것(미방문)을 치료사가 "왔다" 로 읽었다** —
|
|
92
|
+
말귀 실측: 그 달 자취가 예약 하나뿐인데 `방문 1건` · `rgb(73,156,223)`.
|
|
93
|
+
드문 자리가 아니다: 375 에서 칸이 37px, **1189 에서도 111px** 이라 둘 다
|
|
94
|
+
이 꼴이 선다. 넓은 칸의 이름표는 멀쩡해서 그쪽만 보면 안 보인다.
|
|
95
|
+
갈래가 하나면 그 갈래 말로, 섞였으면 갈라 센다. */
|
|
96
|
+
const 갈래 = [
|
|
97
|
+
{ key: 'visited', 말: t('calendar.legend.visited', '방문'),
|
|
98
|
+
수: dayVisits.filter(v => (v.status ?? 'visited') === 'visited').length },
|
|
99
|
+
{ key: 'scheduled', 말: t('calendar.legend.scheduled', '예약'),
|
|
100
|
+
수: dayVisits.filter(v => v.status === 'scheduled').length },
|
|
101
|
+
{ key: 'noVisit', 말: t('calendar.legend.noVisit', '미방문'),
|
|
102
|
+
수: dayVisits.filter(v => v.status === 'noVisit').length },
|
|
103
|
+
].filter(g => g.수 > 0);
|
|
104
|
+
/* 방문뿐인 날은 전 글귀 그대로 간다 — 그 열쇠의 뜻("방문 N건")이 실제로 맞는
|
|
105
|
+
자리이고, 부르는 쪽이 덮어 둔 글도 그대로 산다. */
|
|
106
|
+
const 센말 = 갈래.length === 1 && 갈래[0].key === 'visited'
|
|
107
|
+
? t('calendar.visitCount', { count: dayVisits.length, defaultValue: '방문 {{count}}건' })
|
|
108
|
+
: 갈래.map(g => t('calendar.statusCount', { label: g.말, count: g.수, defaultValue: '{{label}} {{count}}건' })).join(' · ');
|
|
109
|
+
return (_jsxs("span", { className: "calendar-cell__visit-count caption-1", "aria-label": `${날짜말} ${센말}`, children: [갈래.map(g => (_jsx("span", { className: `calendar-cell__visit-dot calendar-cell__visit-dot--${g.key}`, "aria-hidden": "true" }, g.key))), dayVisits.length] }));
|
|
110
|
+
})(), _jsx("div", { className: "calendar-cell__badges", children: dayVisits.map((v, vi) => (_jsxs("div", { draggable: !!onVisitMove, className: `calendar-visit-badge${v.status === 'noVisit' ? ' calendar-visit-badge--no-visit' : v.status === 'scheduled' ? ' calendar-visit-badge--scheduled' : ''}${onVisitClick ? ' calendar-visit-badge--clickable' : ''}${onVisitMove ? ' calendar-visit-badge--draggable' : ''}`, onDragStart: onVisitMove ? (e) => {
|
|
89
111
|
dragVisitId.current = v.id;
|
|
90
112
|
e.dataTransfer.effectAllowed = 'move';
|
|
91
113
|
} : undefined, onDragEnd: onVisitMove ? () => {
|
|
@@ -129,18 +129,40 @@
|
|
|
129
129
|
.panel-grid {
|
|
130
130
|
display: grid;
|
|
131
131
|
grid-template-columns: repeat(var(--panel-grid-cols, 2), 1fr);
|
|
132
|
-
gap: 1px;
|
|
132
|
+
gap: var(--panel-grid-gap, 1px);
|
|
133
133
|
padding-bottom: 1px;
|
|
134
134
|
background: var(--color-darkWhite);
|
|
135
135
|
overflow: hidden;
|
|
136
136
|
}
|
|
137
|
-
.panel-grid .panel-row {
|
|
137
|
+
.panel-grid:not(.panel-grid--columns) .panel-row {
|
|
138
138
|
background: var(--color-white);
|
|
139
139
|
}
|
|
140
|
-
.panel-grid .panel-row + .panel-row {
|
|
140
|
+
.panel-grid:not(.panel-grid--columns) .panel-row + .panel-row {
|
|
141
141
|
border-top: none;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/* 독립 카드: 행 높이를 맞추지 않고 열 안에서 위→아래 순서로 쌓는다. */
|
|
145
|
+
.panel-grid--columns {
|
|
146
|
+
display: block;
|
|
147
|
+
column-count: var(--panel-grid-cols, 2);
|
|
148
|
+
column-gap: var(--panel-grid-gap, 1px);
|
|
149
|
+
padding-bottom: 0;
|
|
150
|
+
background: transparent;
|
|
151
|
+
overflow: visible;
|
|
152
|
+
}
|
|
153
|
+
.panel-grid--columns > * {
|
|
154
|
+
display: inline-flex;
|
|
155
|
+
flex-direction: column;
|
|
156
|
+
width: 100%;
|
|
157
|
+
box-sizing: border-box;
|
|
158
|
+
vertical-align: top;
|
|
159
|
+
break-inside: avoid;
|
|
160
|
+
margin-bottom: var(--panel-grid-gap, 1px);
|
|
161
|
+
}
|
|
162
|
+
.panel-grid--columns > :last-child {
|
|
163
|
+
margin-bottom: 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
144
166
|
/* ── Panel row state (loading / empty) ──────────────────────────────── */
|
|
145
167
|
.panel-row--empty {
|
|
146
168
|
justify-content: center;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode } from 'react';
|
|
1
|
+
import { type ReactNode, type CSSProperties } from 'react';
|
|
2
2
|
import './Panel.css';
|
|
3
3
|
type PanelSize = 'small' | 'medium' | 'large';
|
|
4
4
|
interface PanelProps {
|
|
@@ -64,8 +64,16 @@ interface PanelProps {
|
|
|
64
64
|
}
|
|
65
65
|
export default function Panel({ title, titleLevel, icon, disc, actions, size, minHeight, maxWidth, className, scrollable, scrollbarGutter, compactRows, useBorder, useBg, useRowBorder, labelWidth, form, loading, empty, children }: PanelProps): import("react").JSX.Element;
|
|
66
66
|
/** @public */
|
|
67
|
-
export declare function PanelGrid({ cols, children }: {
|
|
67
|
+
export declare function PanelGrid({ cols, layout, gap, collapseAt, className, style, children, }: {
|
|
68
68
|
cols?: number;
|
|
69
|
+
/** grid: 기존 행·셀 배치. columns: 콘텐츠 높이를 유지하며 위에서 아래로 배치 */
|
|
70
|
+
layout?: 'grid' | 'columns';
|
|
71
|
+
/** 숫자는 px, 문자열은 CSS 길이 또는 디자인 토큰. 기본 1px */
|
|
72
|
+
gap?: number | string;
|
|
73
|
+
/** 컨테이너 너비가 이 값(px) 이하이면 1열. 생략하면 열 개수 유지 */
|
|
74
|
+
collapseAt?: number;
|
|
75
|
+
className?: string;
|
|
76
|
+
style?: CSSProperties;
|
|
69
77
|
children: ReactNode;
|
|
70
78
|
}): import("react").JSX.Element;
|
|
71
79
|
export { PanelRow, PanelRowDevice } from './PanelRow';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { cx } from '../utils/cx';
|
|
4
4
|
import { PanelRow } from './PanelRow';
|
|
5
|
-
import { Children } from 'react';
|
|
5
|
+
import { Children, useEffect, useRef, useState } from 'react';
|
|
6
6
|
import Icon from './Icon';
|
|
7
7
|
import './Panel.css';
|
|
8
8
|
export default function Panel({ title, titleLevel = 2, icon, disc, actions, size = 'medium', minHeight, maxWidth, className, scrollable, scrollbarGutter = 'auto', compactRows = false, useBorder = true, useBg = true, useRowBorder = true, labelWidth, form = false, loading, empty, children }) {
|
|
@@ -23,7 +23,25 @@ export default function Panel({ title, titleLevel = 2, icon, disc, actions, size
|
|
|
23
23
|
}
|
|
24
24
|
// ── PanelGrid ──────────────────────────────────────────────────────────────
|
|
25
25
|
/** @public */
|
|
26
|
-
export function PanelGrid({ cols = 2, children }) {
|
|
27
|
-
|
|
26
|
+
export function PanelGrid({ cols = 2, layout = 'grid', gap = 1, collapseAt, className, style, children, }) {
|
|
27
|
+
const ref = useRef(null);
|
|
28
|
+
const [width, setWidth] = useState();
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const element = ref.current;
|
|
31
|
+
if (collapseAt === undefined || !element)
|
|
32
|
+
return;
|
|
33
|
+
setWidth(element.clientWidth);
|
|
34
|
+
if (typeof ResizeObserver === 'undefined')
|
|
35
|
+
return;
|
|
36
|
+
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
|
|
37
|
+
observer.observe(element);
|
|
38
|
+
return () => observer.disconnect();
|
|
39
|
+
}, [collapseAt]);
|
|
40
|
+
const collapsed = collapseAt !== undefined && width !== undefined && width <= collapseAt;
|
|
41
|
+
return (_jsx("div", { ref: ref, className: cx('panel-grid', layout === 'columns' && 'panel-grid--columns', className), style: {
|
|
42
|
+
'--panel-grid-cols': collapsed ? 1 : cols,
|
|
43
|
+
'--panel-grid-gap': typeof gap === 'number' ? `${gap}px` : gap,
|
|
44
|
+
...style,
|
|
45
|
+
}, children: children }));
|
|
28
46
|
}
|
|
29
47
|
export { PanelRow, PanelRowDevice } from './PanelRow'; // 갈라 나간 뒤에도 이 경로로 나간다
|
|
@@ -140,12 +140,16 @@
|
|
|
140
140
|
justify-content: flex-end;
|
|
141
141
|
min-width: 0;
|
|
142
142
|
}
|
|
143
|
-
/* **덩이째 줄을 바꾸고도 넘치면 안쪽에서 한 번 더
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
143
|
+
/* **덩이째 줄을 바꾸고도 넘치면 안쪽에서 한 번 더 바꾼다** — 375px 에서 오른쪽 단추 둘이 25px 넘쳐
|
|
144
|
+
잘렸다(실측 2026-09-24). 0.185.0 에서 바닥이 잘려 못 누르던 그 꼴이다.
|
|
145
|
+
그 몫은 **덩이 제 `min-width: 0`(위) + `flex-wrap: wrap`** 이 한다 — 덩이가 줄면 안쪽 단추가
|
|
146
|
+
**줄을 바꿔** 서지, 글자가 쪼그라들지 않는다.
|
|
147
|
+
|
|
148
|
+
그래서 아래 `min-width: 0` 은 **단추가 아닌 것에만** 건다. 2026-09-24 에는 단추까지 눌렀는데,
|
|
149
|
+
그러면 넉넉한 폭에서도 `.button48--bar` 의 제 밑폭(160px)이 사라져 글자 폭까지 쪼그라들었다
|
|
150
|
+
(말귀 실측 2026-10-02: '저장' 95px). 잘림은 줄바꿈으로 막고, 밑폭은 단추가 지킨다. */
|
|
151
|
+
.popup-footer__start > *:not(.button),
|
|
152
|
+
.popup-footer__end > *:not(.button) {
|
|
149
153
|
min-width: 0;
|
|
150
154
|
}
|
|
151
155
|
.popup--closing { opacity: 0; transition: opacity 260ms ease }
|
|
@@ -6,11 +6,24 @@ export interface SectionProps {
|
|
|
6
6
|
type?: SectionType;
|
|
7
7
|
title?: ReactNode;
|
|
8
8
|
/**
|
|
9
|
-
* 제목을 이 단계의 제목 태그(`h1`~`h6`)로
|
|
9
|
+
* 제목을 이 단계의 제목 태그(`h1`~`h6`)로 그린다. **기본 2** — 페이지 제목(`h1`) 아래 구획을
|
|
10
|
+
* 가리킨다. 그 아래로 들어가면 3·4 로 내린다. 글자 모양은 어느 쪽이든 `type` 이 정한다.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
+
* **전에는 기본이 `div` 였고 "주는 것을 권한다" 고만 적어 두었다**(2026-10-02 바꿈).
|
|
13
|
+
* 그 권고를 **아무도 안 따랐다** — 말귀 앱 쉰일곱 자리 중 주는 자리가 **0**, 그리고
|
|
14
|
+
* **코어 제 안의 열네 자리도 0** 이었다. 그래서 칸 다섯이 선 화면에 머리글이 `h1` 하나뿐인
|
|
15
|
+
* 꼴이 났다. 형제 다섯(`ChartSection` 3 · `Panel` 2 · `FilterPanel` 2 · `TrainingCard` 3 ·
|
|
16
|
+
* `TrainingCardGroup` 3)은 전부 **안 줘도 머리글이 서는데** 이것만 달랐다 —
|
|
17
|
+
* 같은 물음에 한 집안이 두 가지로 답하고 있었다.
|
|
18
|
+
*
|
|
19
|
+
* **값을 치른다**: 급을 안 주던 자리가 한꺼번에 `h2` 가 된다. 깊이 든 구획이면 층이 한 급
|
|
20
|
+
* 건너뛴 것처럼 읽힐 수 있다 — 그 자리는 숫자를 적어 고친다. **급이 틀린 것은 머리글이
|
|
21
|
+
* 아예 없는 것보다 가볍다**: 틀려도 건너뛸 자리는 생기고, 없으면 길이 없다.
|
|
22
|
+
*
|
|
23
|
+
* `0` 을 주면 전과 같이 `div` 로 그린다(급을 부르는 쪽이 못 정하는 자리용 —
|
|
24
|
+
* `ChartSection.titleLevel` 과 같은 탈출구).
|
|
12
25
|
*/
|
|
13
|
-
headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
26
|
+
headingLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
14
27
|
icon?: string;
|
|
15
28
|
action?: ReactNode;
|
|
16
29
|
disc?: ReactNode;
|
|
@@ -8,7 +8,7 @@ const TITLE_CLASS = {
|
|
|
8
8
|
popup: 'h5',
|
|
9
9
|
};
|
|
10
10
|
/** @public */
|
|
11
|
-
export default function Section({ type = 'page', title, headingLevel, icon, action, disc, children, inGrid = false, className, style }) {
|
|
11
|
+
export default function Section({ type = 'page', title, headingLevel = 2, icon, action, disc, children, inGrid = false, className, style }) {
|
|
12
12
|
const titleClass = TITLE_CLASS[type];
|
|
13
13
|
// action·icon 만 줘도 머리를 그린다 — 제목·설명이 없으면 추가 단추가 조용히 버려졌다
|
|
14
14
|
const hasHeader = title || disc || action || icon;
|
|
@@ -34,10 +34,26 @@ export type TableProps<T extends {
|
|
|
34
34
|
*/
|
|
35
35
|
headerGroups?: TableHeaderGroup[];
|
|
36
36
|
items?: T[];
|
|
37
|
+
/**
|
|
38
|
+
* 줄 하나를 칸 배열로 그린다.
|
|
39
|
+
*
|
|
40
|
+
* **`rowLabel` 을 준 칸은 그 안이 진짜 `<button>` 이 된다**(말귀 요청 (56)(나), 2026-10-02).
|
|
41
|
+
* (가) 에서 줄이 `role="button"` 을 잃으면서 **"눌릴 수 있다" 는 말**이 사라졌고,
|
|
42
|
+
* `aria-describedby` 로 메워 뒀다. 그 자리를 제대로 메우는 길이 이것이다 — 줄 안에
|
|
43
|
+
* **이름 있는 진짜 단추**를 세운다.
|
|
44
|
+
*
|
|
45
|
+
* 왜 코어가 못 정하고 부르는 쪽이 짚어야 하나: **단어 관리의 첫 칸은 체크박스**다.
|
|
46
|
+
* 첫 칸을 단추로 감싸면 단추 안에 조작이 또 들어간다(오늘 (65)·0.254.0 에서 걷은 그 꼴).
|
|
47
|
+
* 그리고 코어는 `content` 가 사람 이름인지 날짜인지 모른다. 그래서 **어느 칸이 줄을
|
|
48
|
+
* 가리키는 이름인지**를 부르는 쪽이 적는다.
|
|
49
|
+
*
|
|
50
|
+
* 안 주면 전과 같다 — 줄이 `tabIndex` 와 설명으로만 눌린다((가) 그대로).
|
|
51
|
+
*/
|
|
37
52
|
renderRow: (item: T) => Array<{
|
|
38
53
|
content: ReactNode;
|
|
39
54
|
className?: string;
|
|
40
55
|
style?: CSSProperties;
|
|
56
|
+
rowLabel?: string;
|
|
41
57
|
}>;
|
|
42
58
|
onRowClick?: (item: T) => void;
|
|
43
59
|
/**
|
|
@@ -22,8 +22,13 @@ import useMediaQuery from '../hooks/useMediaQuery';
|
|
|
22
22
|
/* 칸 하나 — `role="cell"` 이라야 낭독기가 **낱낱으로** 읽는다(말귀 요청 (56), 2026-10-02).
|
|
23
23
|
역할이 없던 때는 줄 전체가 한 덩어리 글로 읽혀 `홍길동 010-1234-5678 2026-09-30 활성`
|
|
24
24
|
이 한 호흡에 나갔다 — 어디까지가 이름이고 어디부터 전화인지 소리로는 알 수 없었다. */
|
|
25
|
-
function TableCell({ children, className, style }) {
|
|
26
|
-
return _jsx("div", { role: "cell", className: className ? `table-cell ${className}` : 'table-cell', style: style, children:
|
|
25
|
+
function TableCell({ children, className, style, rowLabel, onRowClick }) {
|
|
26
|
+
return (_jsx("div", { role: "cell", className: className ? `table-cell ${className}` : 'table-cell', style: style, children: rowLabel && onRowClick
|
|
27
|
+
/* **진짜 `<button>` 이라 Enter·Space 는 브라우저가 처리한다** — 손으로 키를 붙이지
|
|
28
|
+
않는다(`TableField` 의 머리 단추와 같은 본). 클릭은 줄로 올라가 `onRowClick` 이
|
|
29
|
+
한 번 불린다. 겉모양은 CSS 가 지운다(`.table-cell__row-button`). */
|
|
30
|
+
? _jsx("button", { type: "button", className: "table-cell__row-button", "aria-label": rowLabel, children: children })
|
|
31
|
+
: children }));
|
|
27
32
|
}
|
|
28
33
|
/**
|
|
29
34
|
* 줄 하나 — **`role="row"` 다. 전에는 눌릴 수 있으면 `role="button"` 이었다**(말귀 요청 (56)).
|
|
@@ -40,7 +45,7 @@ function TableCell({ children, className, style }) {
|
|
|
40
45
|
* `aria-describedby` 로 "줄을 누르면 자세히 본다" 를 붙여 메운다. 진짜 단추를 줄 안에
|
|
41
46
|
* 세우는 쪽은 다음 걸음이다 — 그때는 줄 이름을 알려 주는 prop 이 하나 필요하다.
|
|
42
47
|
*/
|
|
43
|
-
function TableRow({ variant = 'default', children, className, draggable, onClick, onMouseEnter, onMouseLeave, onDragStart, onDragEnd, expandedContent, colSpan, describedBy }) {
|
|
48
|
+
function TableRow({ variant = 'default', children, className, draggable, onClick, onMouseEnter, onMouseLeave, onDragStart, onDragEnd, expandedContent, colSpan, describedBy, rowHasButton }) {
|
|
44
49
|
const handleKeyDown = useCallback((e) => {
|
|
45
50
|
if (!onClick)
|
|
46
51
|
return;
|
|
@@ -64,7 +69,10 @@ function TableRow({ variant = 'default', children, className, draggable, onClick
|
|
|
64
69
|
return (
|
|
65
70
|
/* 겉싸개는 **나무에서 지운다** — `table > row` 사슬 사이에 역할 없는 마디가 끼면
|
|
66
71
|
그 아래 `row` 가 표의 줄로 안 잡힌다. 자리 잡기(테두리·세로 쌓기)는 그대로다. */
|
|
67
|
-
_jsxs("div", { role: "presentation", className: "table-row-wrap", children: [_jsx("div", { className: className ? `table-row ${className}` : 'table-row', draggable: draggable, onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, onDragStart: onDragStart, onDragEnd: onDragEnd, role: "row",
|
|
72
|
+
_jsxs("div", { role: "presentation", className: "table-row-wrap", children: [_jsx("div", { className: className ? `table-row ${className}` : 'table-row', draggable: draggable, onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, onDragStart: onDragStart, onDragEnd: onDragEnd, role: "row",
|
|
73
|
+
/* 줄 안에 이름 있는 단추가 섰으면 **그쪽이 자판 길이다** — 줄까지 정지점이면 한 줄에
|
|
74
|
+
두 번 멈춘다. 마우스로 줄 아무 데나 누르는 길은 `onClick` 으로 그대로 남는다. */
|
|
75
|
+
tabIndex: onClick && !rowHasButton ? 0 : undefined, "aria-describedby": onClick && !rowHasButton ? describedBy : undefined, children: children }), expandedContent && (_jsx("div", { role: "row", className: "table-row-expand", children: _jsx("div", { role: "cell", "aria-colspan": colSpan, className: "table-row-expand__cell", children: expandedContent }) }))] }));
|
|
68
76
|
}
|
|
69
77
|
function SortArrow({ dir }) {
|
|
70
78
|
const Icon = dir === 'asc' ? ChevronUp : ChevronDown;
|
|
@@ -540,6 +548,10 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
540
548
|
// 옆의 핸들러들처럼 있을 때만 넘긴다. 항상 함수를 만들어 넘기면
|
|
541
549
|
// TableRow 가 "클릭 가능"으로 보고 role="button" · tabIndex=0 을 붙여,
|
|
542
550
|
// 클릭 동작이 없는 표의 모든 행이 버튼으로 읽히고 탭 정지점이 된다.
|
|
543
|
-
onClick: 눌림 ? () => onRowClick(item) : undefined, colSpan: fields.length || undefined,
|
|
551
|
+
onClick: 눌림 ? () => onRowClick(item) : undefined, colSpan: fields.length || undefined,
|
|
552
|
+
/* **줄 안에 진짜 단추가 서면 줄은 정지점에서 내린다** — 한 가지 일에
|
|
553
|
+
정지점이 둘이면 자판으로 도는 사람이 같은 줄을 두 번 잡는다(0.254.0 에서
|
|
554
|
+
접기 머리에 걷은 그 꼴). 설명도 그때는 필요 없다 — 단추가 제 이름으로 말한다. */
|
|
555
|
+
rowHasButton: renderRow(item).some(c => !!c.rowLabel), describedBy: 줄설명id, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, rowLabel: 눌림 ? cell.rowLabel : undefined, onRowClick: 눌림 ? () => onRowClick(item) : undefined, children: cell.content }, i))) }, item.id != null ? `${typeof item.id}:${item.id}` : `__idx:${idx}`));
|
|
544
556
|
}) })] }))] })] }));
|
|
545
557
|
}
|
|
@@ -10,3 +10,32 @@
|
|
|
10
10
|
그려져 두 열의 글자가 포개졌다(머리는 멀쩡한데 본문만). 같은 규칙으로 맞춘다. */
|
|
11
11
|
overflow: hidden;
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/* **줄을 가리키는 칸의 진짜 단추**(말귀 요청 (56)(나), 2026-10-02).
|
|
15
|
+
(가) 에서 줄이 `role="button"` 을 잃어 "눌릴 수 있다" 는 말이 사라졌다 —
|
|
16
|
+
`aria-describedby` 로 메워 뒀던 자리를 이름 있는 진짜 단추로 제대로 메운다.
|
|
17
|
+
**겉모양은 그대로여야 한다** — 칸 안 글자가 단추처럼 보이면 표가 단추밭이 된다.
|
|
18
|
+
그래서 단추의 기본 꼴을 다 걷고 글자만 남긴다. 초점 테두리는 남긴다(어디 있는지 보여야 한다). */
|
|
19
|
+
.table-cell__row-button {
|
|
20
|
+
appearance: none;
|
|
21
|
+
background: none;
|
|
22
|
+
border: 0;
|
|
23
|
+
padding: 0;
|
|
24
|
+
margin: 0;
|
|
25
|
+
font: inherit;
|
|
26
|
+
color: inherit;
|
|
27
|
+
text-align: inherit;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
/* **칸을 꽉 채운다** — 글자 옆 빈 자리를 눌러도 줄이 열리던 것이 그대로 산다. 세로도
|
|
30
|
+
채워야 누름감이 줄 키(48~64px)를 그대로 받는다(`test/rawButtonHitArea.test.ts` 가 이
|
|
31
|
+
`align-self: stretch` 를 키의 바닥으로 읽는다 — `.table-field__button` 과 같은 본).
|
|
32
|
+
속은 칸과 **같은 결로** 놓는다: 칸이 `flex` + 가운데 맞춤이라 단추도 그래야 글이 안 뜬다. */
|
|
33
|
+
align-self: stretch;
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
width: 100%;
|
|
37
|
+
min-width: 0;
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
overflow-wrap: inherit;
|
|
40
|
+
word-break: inherit;
|
|
41
|
+
}
|
|
@@ -182,15 +182,35 @@ export default function TrainingBlockButton(props) {
|
|
|
182
182
|
? (유형열쇠 ? t(`trainingType.${유형열쇠}.title`, { defaultValue: 유형원본.label }) : (유형원본.label ?? ''))
|
|
183
183
|
: '';
|
|
184
184
|
// 타입 라벨이 실제로 렌더되는지(타이틀과 같으면 숨김) — 레이아웃 여백 정리에 사용.
|
|
185
|
-
const
|
|
186
|
-
(isSmall && !!trainingTypeName && trainingTypeName !== String(itemTitle))
|
|
187
|
-
|
|
185
|
+
const 갈래글 = (!!showTypeLabel && !!type?.label && type.label !== String(itemTitle)) ? String(type.label)
|
|
186
|
+
: (isSmall && !!trainingTypeName && trainingTypeName !== String(itemTitle)) ? trainingTypeName
|
|
187
|
+
: '';
|
|
188
|
+
const typeLabelRendered = !!갈래글;
|
|
189
|
+
/* 그려지는 수 — 아래 본문과 **같은 값을 본다.** 전에는 JSX 안에서만 셈해서 이름이 못 썼다. */
|
|
190
|
+
const 그려지는수 = countLabelOverride !== undefined ? countLabelOverride : countLabel;
|
|
191
|
+
/**
|
|
192
|
+
* **눈에 보이는 줄 그대로 이름을 짓는다**(말귀 요청 (65) 의 뒷자락, 2026-10-02).
|
|
193
|
+
*
|
|
194
|
+
* 전에는 `itemTitle` 하나였다. 그런데 한 세트 안에서 **같은 낱말이 여러 갈래에 쓰이는 것은
|
|
195
|
+
* 드문 일이 아니라**, 눈으로는 갈래가 왼쪽에 또렷이 보이는데 **소리로는 안 갈렸다** —
|
|
196
|
+
* 말귀 실측(말귀자료실 미리보기, `종성 ㅁ vs 종성 ㅇ` 카드, 375px):
|
|
197
|
+
*
|
|
198
|
+
* 소음 하 문장 듣기 · 김밥 · 5문장 → 김밥
|
|
199
|
+
* 문장 순서화 · 김밥 · 5문장 → 김밥 ← 같다 (이런 쌍이 셋)
|
|
200
|
+
*
|
|
201
|
+
* `TrainingCard` 는 children 의 뜻을 몰라 부르는 쪽이 넘겨야 하지만, **여기는 코어가
|
|
202
|
+
* 재료를 다 쥐고 있다** — 갈래(`type`·`trainingData`)도 수(`countLabel`)도. 그러니 코어가
|
|
203
|
+
* 짓는다. 가름은 **누가 재료를 들고 있나** 였다.
|
|
204
|
+
*
|
|
205
|
+
* **보이는 것만 담는다** — 갈래글은 실제로 그려질 때만(`typeLabelRendered`), 수도 그려질
|
|
206
|
+
* 때만 넣는다. 그래야 이름과 눈이 어긋나지 않는다(WCAG 2.5.3, 보이는 이름이 읽히는 이름
|
|
207
|
+
* 안에 들어 있어야 한다). 부르는 쪽이 `ariaLabel` 을 주면 그쪽이 먼저다.
|
|
208
|
+
*/
|
|
209
|
+
const 지은이름 = [갈래글, typeof itemTitle === 'string' ? itemTitle : '', 그려지는수]
|
|
210
|
+
.map(x => String(x ?? '').trim()).filter(Boolean).join(' ');
|
|
211
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: btnRef, className: `training-block-button${isList ? ' training-block-button--list' : ''}${isRow ? ' training-block-button--row' : ''}${isCompact ? ' training-block-button--compact' : ''}${isSmall ? ' training-block-button--small' : ''}${isCompleted ? ' training-block-button--completed' : ''}${isSelected ? ' training-block-button--selected' : ''}${typeLabelRendered && !isSmall ? ' training-block-button--withType' : ''}${isSmall && !typeLabelRendered ? ' training-block-button--no-type-label' : ''}`, onClick: handleActivate, onPointerEnter: handleBtnHoverOn, onPointerLeave: handleBtnHoverClose, role: "button", tabIndex: 0, onKeyDown: handleKeyDown, "aria-label": typeof ariaLabel === 'string' ? ariaLabel : (지은이름 || undefined),
|
|
188
212
|
/* tooltipText 만 주고 useDescriptionTooltip 을 안 켜면 글이 어디에도 안 떴다 — 그때는 title(가리키면 뜨고 낭독기엔 설명)로 준다 */
|
|
189
|
-
title: !useDescriptionTooltip && tooltipText ? (Array.isArray(tooltipText) ? tooltipText.filter(Boolean).join('\n') : tooltipText) || undefined : undefined, children: [_jsxs("div", { className: "training-block-button__left", children: [showTypeLabel && type?.label && type.label !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-1", children: type.label })), isSmall && trainingTypeName && trainingTypeName !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-2", children: trainingTypeName })), _jsx(CategoryIcon, { icon: iconForBtn, label: itemTitle, tooltip: popupIconTooltip, wrapperStyle: wrapperStyle, iconSize: (isSmall || isCompact) ? 'small' : 'medium' }), !hideCheckBadge && (isCompleted || isSelected) && !(onRemove && btnHover) && (_jsx(Badge, { className: onRemove ? 'training-block-button__check--removable' : undefined, type: "dot", icon: "check", position: "top-left", size: "medium", "aria-label": isCompleted ? t('training.completed', '훈련 완료') : t('training.selected', '선택됨') })), onRemove && (_jsx(Badge, { className: `training-block-button__remove${btnHover ? ' is-hover' : ''}`, type: "dot", icon: "remove", position: "top-left", size: "medium", "aria-label": t('common.remove', '제거'), onClick: handleRemoveClick })), _jsx("div", { className: `training-block-button__title ${isCompact ? 'body-3' : isSmall ? 'body-3b' : 'h3'}`, children: String(itemTitle) }), _jsxs("div", { className: "training-block-button__description", children: [(()
|
|
190
|
-
const countText = countLabelOverride !== undefined ? countLabelOverride : countLabel;
|
|
191
|
-
// 정해진 문항수가 없으면(빈 값) 카운트 요소 자체를 그리지 않는다.
|
|
192
|
-
return countText ? (_jsx("div", { className: `training-block-button__count ${isSmall ? 'caption-2' : 'caption-1'}`, children: countText })) : null;
|
|
193
|
-
})(), _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), ((isList && !isRow) || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { htmlType: "button", size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing,
|
|
213
|
+
title: !useDescriptionTooltip && tooltipText ? (Array.isArray(tooltipText) ? tooltipText.filter(Boolean).join('\n') : tooltipText) || undefined : undefined, children: [_jsxs("div", { className: "training-block-button__left", children: [showTypeLabel && type?.label && type.label !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-1", children: type.label })), isSmall && trainingTypeName && trainingTypeName !== String(itemTitle) && (_jsx("span", { className: "training-block-button__type-label caption-2", children: trainingTypeName })), _jsx(CategoryIcon, { icon: iconForBtn, label: itemTitle, tooltip: popupIconTooltip, wrapperStyle: wrapperStyle, iconSize: (isSmall || isCompact) ? 'small' : 'medium' }), !hideCheckBadge && (isCompleted || isSelected) && !(onRemove && btnHover) && (_jsx(Badge, { className: onRemove ? 'training-block-button__check--removable' : undefined, type: "dot", icon: "check", position: "top-left", size: "medium", "aria-label": isCompleted ? t('training.completed', '훈련 완료') : t('training.selected', '선택됨') })), onRemove && (_jsx(Badge, { className: `training-block-button__remove${btnHover ? ' is-hover' : ''}`, type: "dot", icon: "remove", position: "top-left", size: "medium", "aria-label": t('common.remove', '제거'), onClick: handleRemoveClick })), _jsx("div", { className: `training-block-button__title ${isCompact ? 'body-3' : isSmall ? 'body-3b' : 'h3'}`, children: String(itemTitle) }), _jsxs("div", { className: "training-block-button__description", children: [그려지는수 ? (_jsx("div", { className: `training-block-button__count ${isSmall ? 'caption-2' : 'caption-1'}`, children: 그려지는수 })) : null, _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), ((isList && !isRow) || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { htmlType: "button", size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing,
|
|
194
214
|
/* **칸 이름을 넣는다**(말귀 요청 (54)②, 2026-10-02). 묶음 칸이 스물넷이면
|
|
195
215
|
낭독기로 단추 목록을 불렀을 때 `미리 듣기` 가 **스물네 번** 선다. 바로 옆
|
|
196
216
|
'더보기' 는 ㊺ 에서 이미 칸 이름을 달았는데 이쪽만 안 따라왔다 — 고칠 값은
|
|
@@ -16,6 +16,20 @@ export type TrainingCardProps = {
|
|
|
16
16
|
description?: ReactNode;
|
|
17
17
|
/** 우측 상단 액션 영역 (좋아요/다운로드, ⋯ 메뉴 등) — 클릭이 카드로 전파되지 않음 */
|
|
18
18
|
topActions?: ReactNode;
|
|
19
|
+
/**
|
|
20
|
+
* **카드가 눌릴 때 읽히는 이름**(말귀 요청 (65), 2026-10-02).
|
|
21
|
+
*
|
|
22
|
+
* 안 주면 `title` 을 쓴다. 안 쓰고 두면 `role="button"` 의 이름이 **안 글자 전부**로
|
|
23
|
+
* 지어지는데, 이 카드는 안에 `topActions`(진짜 `<button>`)를 품으므로 **그 단추의 이름까지
|
|
24
|
+
* 삼킨다** — 말귀 실측(나의 단어훈련 375px, 카드 여섯 장 전부):
|
|
25
|
+
* `훈련하자!! (복사) 훈련하자!! (복사) 더보기 음절 2개 합치기 1 …` 로, 제목이 두 번 읽히고
|
|
26
|
+
* 사이에 `더보기` 가 꼈다. `TrainingBlockButton` 이 이미 같은 길로 풀어 둔 자리다.
|
|
27
|
+
*
|
|
28
|
+
* **값을 치른다** — 갈래별 수와 마지막 쓴 날이 이름에서 빠진다. 그것까지 들려주려면
|
|
29
|
+
* 부르는 쪽이 여기에 **갖춰 쓴 이름**을 넘겨라(`'훈련하자!! · 음절 합치기 1 · 3개월 전'`).
|
|
30
|
+
* 코어는 children 의 뜻을 모르므로 제목까지만 지을 수 있다.
|
|
31
|
+
*/
|
|
32
|
+
ariaLabel?: string;
|
|
19
33
|
/** 본문 영역 콘텐츠 (타입 칩, 단어 뱃지, 빈 상태 등) */
|
|
20
34
|
children?: ReactNode;
|
|
21
35
|
/** 풋터 좌측 (게시자·날짜 등) */
|
|
@@ -29,4 +43,4 @@ export type TrainingCardProps = {
|
|
|
29
43
|
className?: string;
|
|
30
44
|
};
|
|
31
45
|
/** @public */
|
|
32
|
-
export default function TrainingCard({ title, titleLevel, description, topActions, children, footerLeft, footerRight, onClick, draggable, onDragStart, onDragEnd, className, }: TrainingCardProps): import("react").JSX.Element;
|
|
46
|
+
export default function TrainingCard({ title, titleLevel, description, topActions, children, footerLeft, footerRight, ariaLabel, onClick, draggable, onDragStart, onDragEnd, className, }: TrainingCardProps): import("react").JSX.Element;
|
|
@@ -4,7 +4,7 @@ import { cx } from '../../utils/cx';
|
|
|
4
4
|
import './TrainingCard.css';
|
|
5
5
|
import { useCallback } from 'react';
|
|
6
6
|
/** @public */
|
|
7
|
-
export default function TrainingCard({ title, titleLevel = 3, description, topActions, children, footerLeft, footerRight, onClick, draggable, onDragStart, onDragEnd, className, }) {
|
|
7
|
+
export default function TrainingCard({ title, titleLevel = 3, description, topActions, children, footerLeft, footerRight, ariaLabel, onClick, draggable, onDragStart, onDragEnd, className, }) {
|
|
8
8
|
const 제목태그 = titleLevel ? `h${titleLevel}` : 'div';
|
|
9
9
|
const hasFooter = footerLeft != null || footerRight != null;
|
|
10
10
|
const hasBody = children != null && children !== false;
|
|
@@ -20,7 +20,7 @@ export default function TrainingCard({ title, titleLevel = 3, description, topAc
|
|
|
20
20
|
onClick();
|
|
21
21
|
}
|
|
22
22
|
}, [onClick]);
|
|
23
|
-
return (_jsxs("div", { className: cx('training-card', onClick ? 'training-card--clickable' : '', className), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, draggable: draggable, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsxs("div", { className: "training-card__header", children: [_jsxs("div", { className: "training-card__title-row", children: [typeof title === 'string'
|
|
23
|
+
return (_jsxs("div", { className: cx('training-card', onClick ? 'training-card--clickable' : '', className), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, "aria-label": onClick ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, tabIndex: onClick ? 0 : undefined, draggable: draggable, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsxs("div", { className: "training-card__header", children: [_jsxs("div", { className: "training-card__title-row", children: [typeof title === 'string'
|
|
24
24
|
? _jsx(제목태그, { className: "training-card__title h4", children: title })
|
|
25
25
|
: title, topActions != null && (_jsx("div", { className: "training-card__top-actions", onClick: (e) => e.stopPropagation(), children: topActions }))] }), description != null
|
|
26
26
|
? (typeof description === 'string'
|
|
@@ -20,7 +20,14 @@ export type TrainingCardGroupProps = {
|
|
|
20
20
|
* 안 잡혔다. 그 안의 `접기`·`정렬` 은 그룹마다 같은 이름이라, 낭독기로 단추 목록을
|
|
21
21
|
* 부르면 `접기` 가 세 번 선다 — **어느 그룹의 것인지 가릴 길이 없었다.**
|
|
22
22
|
*
|
|
23
|
-
* 이름 있는 지역이 서면 단추 이름은 짧은 채로 둬도 된다(지역이 가린다)
|
|
23
|
+
* **전에 여기 "이름 있는 지역이 서면 단추 이름은 짧은 채로 둬도 된다(지역이 가린다)" 고
|
|
24
|
+
* 적어 두었는데, 그 말이 틀렸다**(2026-10-02 바로잡음). `role="group"` 은 **landmark 가
|
|
25
|
+
* 아니다.** 초점이 묶음 안으로 들어가 읽을 때는 묶음 이름이 먼저 읽히니 맞지만,
|
|
26
|
+
* **낭독기로 단추 목록을 훑는 길**에서는 묶음 맥락이 안 따라온다 — 바로 윗줄에 적은
|
|
27
|
+
* "`접기` 가 세 번 선다" 가 그대로 남는다. 말귀가 그 한 줄을 믿고 앱 세 파일에서 그룹
|
|
28
|
+
* 이름을 걷으려다 물어 와서 드러났다.
|
|
29
|
+
* 그래서 **이 조각이 제 단추에 묶음 이름을 직접 단다**(아래 `접기`·`정렬`). 부르는 쪽이
|
|
30
|
+
* actions 로 넘기는 단추에도 묶음 이름을 다는 쪽이 맞다 — 걷지 마라.
|
|
24
31
|
* `0` 을 주면 전과 같이 `span` 으로 그린다. */
|
|
25
32
|
titleLevel?: 0 | 2 | 3 | 4 | 5 | 6;
|
|
26
33
|
/** 그룹 제목 — 문자열이면 제목으로 감싸고, 노드면 그대로 렌더(예: 인라인 이름변경 입력) */
|