@iyulab/components 1.40.2 → 1.41.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 +62 -0
- package/README.md +31 -0
- package/dist/components/UElement.js +25 -0
- package/dist/components/UFormControlElement.d.ts +36 -0
- package/dist/components/UFormControlElement.js +56 -1
- package/dist/components/checkbox/UCheckbox.js +3 -1
- package/dist/components/date-picker/UDatePicker.js +2 -2
- package/dist/components/field/UField.d.ts +46 -0
- package/dist/components/field/UField.js +82 -3
- package/dist/components/file-input/UFileInput.d.ts +11 -0
- package/dist/components/file-input/UFileInput.js +15 -0
- package/dist/components/input/UInput.js +2 -2
- package/dist/components/radio/URadio.js +2 -2
- package/dist/components/rating/URating.js +2 -2
- package/dist/components/select/USelect.js +3 -3
- package/dist/components/slider/USlider.js +1 -1
- package/dist/components/split-panel/USplitPanel.js +9 -1
- package/dist/components/switch/USwitch.js +9 -1
- package/dist/components/textarea/UTextarea.js +2 -2
- package/dist/utilities/accent.d.ts +1 -1
- package/dist/utilities/accent.js +1 -1
- package/dist/utilities/devWarning.d.ts +9 -1
- package/dist/utilities/devWarning.js +16 -2
- package/package.json +3 -3
- package/skills/iyulab-components/references/components/field.md +37 -0
- package/skills/iyulab-components/references/components/switch.md +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.41.0] - 2026-09-16
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`u-field` drew its label but never connected it to the control it wraps** — the shadow `label`
|
|
8
|
+
had no `for`, and the slotted control's `id`, `aria-label` and `aria-labelledby` were all empty.
|
|
9
|
+
Every control in a labelled form was therefore unnamed in the accessibility tree while looking
|
|
10
|
+
completely normal on screen, with no console signal. This is the exact shape this package's own
|
|
11
|
+
reference documentation teaches (`<u-field label="Email"><u-input></u-input></u-field>`), so
|
|
12
|
+
following the docs produced the defect. `u-field` now copies its `label` onto the slotted element
|
|
13
|
+
as `aria-label` (and `description` as `aria-description`); `aria-labelledby` is not usable here
|
|
14
|
+
because it does not cross shadow boundaries.
|
|
15
|
+
- **`u-field.focus()` and clicking its label did nothing when a custom-element control was
|
|
16
|
+
slotted** — the target search used a focusability test that does not look inside shadow roots, so
|
|
17
|
+
it only ever matched native elements. It now also recognizes form-associated custom elements, and
|
|
18
|
+
falls back to searching inside a slotted wrapper.
|
|
19
|
+
- **`u-switch` ignored its inherited `label` property** — unlike its sibling `u-checkbox`, the
|
|
20
|
+
default slot had no fallback, so `<u-switch label="…">` rendered neither a visible label nor an
|
|
21
|
+
accessible name.
|
|
22
|
+
- **A field label did not reach `u-checkbox`, `u-switch` or `u-file-input`.** Those three take
|
|
23
|
+
their accessible name from their own content — the first two wrap the native control in a
|
|
24
|
+
`<label>`, and the third's focusable element is a "choose file" button — so the mechanism that
|
|
25
|
+
works for the other seven controls did nothing for them. An empty checkbox or switch inside a
|
|
26
|
+
labelled field was named by nothing but its required marker. They now take the field's label
|
|
27
|
+
when, and only when, they have no name of their own.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **Form controls now forward a host `aria-label` / `aria-description` to the native control inside
|
|
32
|
+
their shadow root.** Previously only `u-button` did this, so setting `aria-label` directly on
|
|
33
|
+
`u-input`, `u-select`, `u-textarea`, `u-date-picker`, `u-radio`, `u-rating` or `u-slider` had no
|
|
34
|
+
effect on the accessible name. A control's own `label` still wins over the host attribute.
|
|
35
|
+
- **Two development-mode warnings on `u-field`**: a label with nothing focusable in the default
|
|
36
|
+
slot (the label names nothing), and a label set on both the field and the slotted control (the
|
|
37
|
+
label renders twice).
|
|
38
|
+
|
|
39
|
+
### Notes
|
|
40
|
+
|
|
41
|
+
- A control that already carries its own `label`, `aria-label` or `aria-labelledby` is left
|
|
42
|
+
untouched — overriding it would make the visible label and the accessible name disagree
|
|
43
|
+
(WCAG SC 2.5.3 Label in Name).
|
|
44
|
+
- `u-file-input` **composes** rather than overrides: its trigger is named "<visible text>,
|
|
45
|
+
<field label>", so the visible text stays part of the accessible name (SC 2.5.3) while several
|
|
46
|
+
file pickers on one screen remain distinguishable.
|
|
47
|
+
- All ten form controls are covered by a reach gate that drives each one in a real browser
|
|
48
|
+
(`tests/browser/field-name-reach-census.browser.test.ts`). A new form control has to be added to
|
|
49
|
+
that table or it sits outside the gate.
|
|
50
|
+
|
|
51
|
+
## [1.40.3] - 2026-09-13
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **`createDevWarner`/`devWarnOnce`, and the `u-icon`/`u-split-panel` warnings built on them, were
|
|
56
|
+
a complete no-op in every published release since 1.40.1** — the guard used
|
|
57
|
+
`import.meta.env.DEV`, which Vite resolves statically at this library's own build time (not the
|
|
58
|
+
consumer's), so the published dist always shipped with `false` baked in and Rollup's dead-code
|
|
59
|
+
elimination removed the warning body entirely. No published dev-mode warning from this package
|
|
60
|
+
has ever actually fired for a consumer. Replaced with `process.env.NODE_ENV !== 'production'`,
|
|
61
|
+
which this library's own build leaves untouched and each consumer's own bundler resolves against
|
|
62
|
+
its own dev/production build (the same convention used by other libraries in this ecosystem for
|
|
63
|
+
the same reason). A new test asserts the built dist directly, not just the source.
|
|
64
|
+
|
|
3
65
|
## [1.40.2] - 2026-09-13
|
|
4
66
|
|
|
5
67
|
### Added
|
package/README.md
CHANGED
|
@@ -128,6 +128,37 @@ Theme.set('system');
|
|
|
128
128
|
|
|
129
129
|
자세한 내용은 [docs/theming.md](./docs/theming.md)를 참고하세요.
|
|
130
130
|
|
|
131
|
+
## Accessibility
|
|
132
|
+
|
|
133
|
+
기준판은 **WCAG 2.2** 입니다. 아래는 이 패키지가 **테스트로 재서 보장하는 것**이고, 그 밖의
|
|
134
|
+
성공 기준까지 포함한 전체 준수 선언이 아닙니다.
|
|
135
|
+
|
|
136
|
+
| 성공 기준 | 보장 | 어디서 재는가 |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| SC 1.4.3 · 1.4.11 명암비 | 역할 토큰의 `-color`·`-color-strong`·`-bg-color` 단계가 텍스트 4.5 / 비텍스트 3.0 을 라이트·다크 양쪽에서 충족 | `tests/build/token-contrast.test.ts` |
|
|
139
|
+
| SC 2.5.8 타깃 크기(최소) | 등록된 모든 컴포넌트의 포인터 타깃이 24×24 CSS px 이상이거나 간격 예외(중심 간 24px)를 충족하고, 그 좌표에서 실제로 눌린다 | `tests/browser/target-size.browser.test.ts`(실제 크로미움) |
|
|
140
|
+
| SC 2.1.1 키보드(포인터 커서 검사) | 픽스처가 그리는 어떤 요소도 포인터 커서를 보이면서 상호작용 요소가 아닌 채로 있지 않다 — 클릭만 받는 `div` 가 들어오지 못한다(키 처리 자체는 컴포넌트 테스트가 잰다) | 같은 파일 |
|
|
141
|
+
| SC 1.3.1 정보와 관계 · SC 4.1.2 이름·역할·값 | `u-field` 의 `label`/`description` 이 슬롯된 컨트롤의 접근성 이름·설명으로 실제로 도달한다(네이티브 엘리먼트 · `u-*` 폼 컨트롤 양쪽) | `tests/browser/field-label-association.browser.test.ts` |
|
|
142
|
+
|
|
143
|
+
🔴**폼 컨트롤의 접근성 이름은 «한 곳에서만» 준다.** `u-field` 는 자기 `label` 을 슬롯된 컨트롤에
|
|
144
|
+
`aria-label` 로 얹고(섀도우 경계를 넘지 못하는 `aria-labelledby` 대신 문자열 복사다), 폼 컨트롤은
|
|
145
|
+
호스트의 `aria-label` 을 내부 네이티브 컨트롤로 내려보낸다. **컨트롤이 자기 `label`·`aria-label`·
|
|
146
|
+
`aria-labelledby` 를 이미 가지면 `u-field` 는 덮지 않는다** — 덮으면 눈에 보이는 라벨과 접근성
|
|
147
|
+
이름이 어긋난다(SC 2.5.3 Label in Name). 양쪽에 `label` 을 주면 라벨이 **두 번 그려지고**, 개발
|
|
148
|
+
모드 콘솔이 그 자리를 지목한다.
|
|
149
|
+
|
|
150
|
+
```html
|
|
151
|
+
<!-- ✅ 라벨은 한 곳 -->
|
|
152
|
+
<u-field label="Keyword"><u-input></u-input></u-field>
|
|
153
|
+
<u-input label="Keyword"></u-input>
|
|
154
|
+
|
|
155
|
+
<!-- ❌ 두 번 그려진다 -->
|
|
156
|
+
<u-field label="Keyword"><u-input label="Keyword"></u-input></u-field>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
⚠ 타깃 크기 보장은 `--u-density` **`14px` 이상**에서 성립합니다 — 컨트롤 패딩이 `em` 이라 그보다
|
|
160
|
+
낮추면 타깃이 함께 줄어듭니다([docs/theming.md](./docs/theming.md)의 밀도 절 참고).
|
|
161
|
+
|
|
131
162
|
## Localization
|
|
132
163
|
|
|
133
164
|
라이브러리가 **스스로 생성하는 문자열**(검증 메시지)은 내장 로케일 14종을 갖고 있으며,
|
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
import { styles } from "./UElement.styles.js";
|
|
2
2
|
import { LitElement, render } from "lit";
|
|
3
3
|
//#region src/components/UElement.ts
|
|
4
|
+
/**
|
|
5
|
+
* 모든 UI 컴포넌트의 기반 클래스.
|
|
6
|
+
* LitElement를 확장하여 이벤트 발행 및 렌더 교체 헬퍼를 제공합니다.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* 디자인 토큰 시트 부재를 개발 빌드에서 1회 경고한다.
|
|
10
|
+
*
|
|
11
|
+
* 토큰이 없으면 컴포넌트 시트의 `var(--u-…)` 가 전부 무효가 되어 테두리·배경이 **에러 없이**
|
|
12
|
+
* 사라진다. CSS 는 이때 아무 신호도 내지 않으므로, 소비자는 자기 CSS 를 의심하며 며칠을 쓴다.
|
|
13
|
+
* 실제로 운영 화면이 무스타일로 렌더된 사례가 있었다 — 토큰 주입이 `Theme.init()` 호출
|
|
14
|
+
* (셸이 대신 부른다)에만 딸려 있어서, 셸 밖에서 렌더되는 로그인 화면만 조용히 깨졌다.
|
|
15
|
+
*
|
|
16
|
+
* ★리터럴 폴백(1.14.0)이 그 **소멸**은 막는다. 그래도 경고는 유지한다 — 폴백은 라이트
|
|
17
|
+
* 시트 값을 구워 넣은 것이라 **다크 테마가 라이트 색으로 렌더되고**, 테마 변수 오버라이드도
|
|
18
|
+
* 먹지 않는다. 폴백은 *"조용히 사라지지 않게"* 하는 안전망이지 *"시트 없이 써도 된다"* 가
|
|
19
|
+
* 아니다. 둘을 섞으면 이 경고가 고발하는 무증상 열화가 형태만 바꿔 돌아온다.
|
|
20
|
+
*/
|
|
21
|
+
var tokenCheckDone = false;
|
|
22
|
+
function warnIfTokensMissing() {
|
|
23
|
+
if (tokenCheckDone || typeof document === "undefined") return;
|
|
24
|
+
tokenCheckDone = true;
|
|
25
|
+
if (getComputedStyle(document.documentElement).getPropertyValue("--u-blue-600").trim()) return;
|
|
26
|
+
console.warn("[@iyulab/components] No design-token sheet found in the document — components render with their built-in fallbacks (baked from the light theme). Dark theme and theme variable overrides will not apply.\n Static CSS: import '@iyulab/components/styles/tokens.css'\n At runtime: Theme.init()\n (The @iyulab/modern-app shell calls Theme.init() for you. Screens rendered outside the shell — login, onboarding, embeds — need one of the two.)");
|
|
27
|
+
}
|
|
4
28
|
var UElement = class extends LitElement {
|
|
5
29
|
static {
|
|
6
30
|
this.styles = styles;
|
|
7
31
|
}
|
|
8
32
|
connectedCallback() {
|
|
9
33
|
super.connectedCallback();
|
|
34
|
+
if (process.env.NODE_ENV !== "production") warnIfTokensMissing();
|
|
10
35
|
}
|
|
11
36
|
/**
|
|
12
37
|
* 커스텀 이벤트를 생성하여 발행합니다.
|
|
@@ -62,6 +62,42 @@ export declare abstract class UFormControlElement<T> extends UElement {
|
|
|
62
62
|
*/
|
|
63
63
|
protected commit(flags: ValidityStateFlags, message: string, anchor?: HTMLElement): void;
|
|
64
64
|
connectedCallback(): void;
|
|
65
|
+
/**
|
|
66
|
+
* 호스트에 세팅된 `aria-label`/`aria-description` 은 접근성 트리에 노출되는 노드가 아니다 —
|
|
67
|
+
* 그것은 shadow DOM 안쪽의 네이티브 컨트롤이고, 섀도우 경계를 넘지 않으므로 자동으로
|
|
68
|
+
* 반영되지 않는다(속성은 붙어 있는데 접근 가능한 이름이 빈 채로 남는다).
|
|
69
|
+
*
|
|
70
|
+
* ⚠**이 처방은 오랫동안 `u-button` 한 곳에만 있었다.** 폼 컨트롤 열 종이 같은 구조인데
|
|
71
|
+
* 같은 경로가 없어서, 바깥에서 라벨을 소유하는 래퍼(`u-field`)가 슬롯된 컨트롤에 이름을
|
|
72
|
+
* 줄 수단 자체가 없었다 — 라벨은 그려지는데 컨트롤은 이름이 없는 «조용한 결함» 이다.
|
|
73
|
+
* ⇒ 사본을 열 벌 만드는 대신 **공통 기반에 한 번** 둔다.
|
|
74
|
+
*
|
|
75
|
+
* ⚠`aria-label` 은 Lit 리액티브 프로퍼티가 아니라 `observedAttributes` 에 없다 — 그 목록에
|
|
76
|
+
* 없는 속성은 `attributeChangedCallback` 자체가 호출되지 않아(커스텀 엘리먼트 표준 동작)
|
|
77
|
+
* 초기 렌더만 되고 연결 후 변경이 반영되지 않는다. 명시적으로 추가해야 한다.
|
|
78
|
+
*/
|
|
79
|
+
static get observedAttributes(): string[];
|
|
80
|
+
attributeChangedCallback(name: string, old: string | null, value: string | null): void;
|
|
81
|
+
/**
|
|
82
|
+
* 내부 네이티브 컨트롤에 실을 접근성 이름. 자기 `label` 이 우선이고, 없으면 호스트의
|
|
83
|
+
* `aria-label` 을 쓴다 — 후자는 `u-field` 같은 «바깥 라벨 소유자» 가 채우는 경로다.
|
|
84
|
+
*/
|
|
85
|
+
protected get resolvedAriaLabel(): string | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* 이름을 **자기 내용에서** 얻는 컨트롤(`u-checkbox`·`u-switch`)용 축.
|
|
88
|
+
*
|
|
89
|
+
* 그 둘은 네이티브 `<label>` 이 컨트롤을 감싸는 구조라, 감싸개 안의 텍스트가 곧 접근성
|
|
90
|
+
* 이름이다 — 그래서 위 `resolvedAriaLabel` 처럼 **무조건** 얹으면 눈에 보이는 라벨을
|
|
91
|
+
* 덮어쓴다(WCAG SC 2.5.3 Label in Name). ⇒ 자기 `label` 도 슬롯 내용도 없어서 감싸개가
|
|
92
|
+
* **비어 있을 때만** 호스트의 `aria-label` 을 쓴다. 전수 실측에서 라벨을 단 `u-field`
|
|
93
|
+
* 안에 빈 체크박스/스위치를 넣으면 이름이 «별표 하나» 뿐이었다.
|
|
94
|
+
*/
|
|
95
|
+
protected get contentAriaLabel(): string | undefined;
|
|
96
|
+
protected hasSlottedLabel: boolean;
|
|
97
|
+
/** 기본 슬롯에 실제 내용이 들어왔는지 — 공백만 있는 텍스트 노드는 내용이 아니다. */
|
|
98
|
+
protected handleLabelSlotChange: (e: Event) => void;
|
|
99
|
+
/** `resolvedAriaLabel` 과 같은 규칙의 설명 축. */
|
|
100
|
+
protected get resolvedAriaDescription(): string | undefined;
|
|
65
101
|
protected updated(changedProperties: PropertyValues): void;
|
|
66
102
|
/**
|
|
67
103
|
* 어떤 속성이 바뀌었을 때 `setValidity()`를 다시 호출할지 결정합니다.
|
|
@@ -2,7 +2,7 @@ import { UElement } from "./UElement.js";
|
|
|
2
2
|
import __decorateMetadata from "../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
|
|
3
3
|
import __decorate from "../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
|
|
4
4
|
import { styles } from "./UFormControlElement.styles.js";
|
|
5
|
-
import { property } from "lit/decorators.js";
|
|
5
|
+
import { property, state } from "lit/decorators.js";
|
|
6
6
|
//#region src/components/UFormControlElement.ts
|
|
7
7
|
/**
|
|
8
8
|
* 폼 컨트롤 컴포넌트의 공통 기반 클래스입니다.
|
|
@@ -17,6 +17,11 @@ var UFormControlElement = class extends UElement {
|
|
|
17
17
|
this.invalid = false;
|
|
18
18
|
this.novalidate = false;
|
|
19
19
|
this.customMessage = "";
|
|
20
|
+
this.hasSlottedLabel = false;
|
|
21
|
+
this.handleLabelSlotChange = (e) => {
|
|
22
|
+
const slot = e.target;
|
|
23
|
+
this.hasSlottedLabel = slot.assignedNodes({ flatten: true }).some((n) => n.nodeType === Node.ELEMENT_NODE || !!n.textContent?.trim());
|
|
24
|
+
};
|
|
20
25
|
}
|
|
21
26
|
static {
|
|
22
27
|
this.styles = [super.styles, styles];
|
|
@@ -68,6 +73,55 @@ var UFormControlElement = class extends UElement {
|
|
|
68
73
|
super.connectedCallback();
|
|
69
74
|
if (!this.internals && "attachInternals" in this) this.internals = this.attachInternals();
|
|
70
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* 호스트에 세팅된 `aria-label`/`aria-description` 은 접근성 트리에 노출되는 노드가 아니다 —
|
|
78
|
+
* 그것은 shadow DOM 안쪽의 네이티브 컨트롤이고, 섀도우 경계를 넘지 않으므로 자동으로
|
|
79
|
+
* 반영되지 않는다(속성은 붙어 있는데 접근 가능한 이름이 빈 채로 남는다).
|
|
80
|
+
*
|
|
81
|
+
* ⚠**이 처방은 오랫동안 `u-button` 한 곳에만 있었다.** 폼 컨트롤 열 종이 같은 구조인데
|
|
82
|
+
* 같은 경로가 없어서, 바깥에서 라벨을 소유하는 래퍼(`u-field`)가 슬롯된 컨트롤에 이름을
|
|
83
|
+
* 줄 수단 자체가 없었다 — 라벨은 그려지는데 컨트롤은 이름이 없는 «조용한 결함» 이다.
|
|
84
|
+
* ⇒ 사본을 열 벌 만드는 대신 **공통 기반에 한 번** 둔다.
|
|
85
|
+
*
|
|
86
|
+
* ⚠`aria-label` 은 Lit 리액티브 프로퍼티가 아니라 `observedAttributes` 에 없다 — 그 목록에
|
|
87
|
+
* 없는 속성은 `attributeChangedCallback` 자체가 호출되지 않아(커스텀 엘리먼트 표준 동작)
|
|
88
|
+
* 초기 렌더만 되고 연결 후 변경이 반영되지 않는다. 명시적으로 추가해야 한다.
|
|
89
|
+
*/
|
|
90
|
+
static get observedAttributes() {
|
|
91
|
+
return [
|
|
92
|
+
...super.observedAttributes,
|
|
93
|
+
"aria-label",
|
|
94
|
+
"aria-description"
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
attributeChangedCallback(name, old, value) {
|
|
98
|
+
super.attributeChangedCallback(name, old, value);
|
|
99
|
+
if (name === "aria-label" || name === "aria-description") this.requestUpdate();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 내부 네이티브 컨트롤에 실을 접근성 이름. 자기 `label` 이 우선이고, 없으면 호스트의
|
|
103
|
+
* `aria-label` 을 쓴다 — 후자는 `u-field` 같은 «바깥 라벨 소유자» 가 채우는 경로다.
|
|
104
|
+
*/
|
|
105
|
+
get resolvedAriaLabel() {
|
|
106
|
+
return this.label ?? this.getAttribute("aria-label") ?? void 0;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 이름을 **자기 내용에서** 얻는 컨트롤(`u-checkbox`·`u-switch`)용 축.
|
|
110
|
+
*
|
|
111
|
+
* 그 둘은 네이티브 `<label>` 이 컨트롤을 감싸는 구조라, 감싸개 안의 텍스트가 곧 접근성
|
|
112
|
+
* 이름이다 — 그래서 위 `resolvedAriaLabel` 처럼 **무조건** 얹으면 눈에 보이는 라벨을
|
|
113
|
+
* 덮어쓴다(WCAG SC 2.5.3 Label in Name). ⇒ 자기 `label` 도 슬롯 내용도 없어서 감싸개가
|
|
114
|
+
* **비어 있을 때만** 호스트의 `aria-label` 을 쓴다. 전수 실측에서 라벨을 단 `u-field`
|
|
115
|
+
* 안에 빈 체크박스/스위치를 넣으면 이름이 «별표 하나» 뿐이었다.
|
|
116
|
+
*/
|
|
117
|
+
get contentAriaLabel() {
|
|
118
|
+
if (this.label || this.hasSlottedLabel) return void 0;
|
|
119
|
+
return this.getAttribute("aria-label") ?? void 0;
|
|
120
|
+
}
|
|
121
|
+
/** `resolvedAriaLabel` 과 같은 규칙의 설명 축. */
|
|
122
|
+
get resolvedAriaDescription() {
|
|
123
|
+
return this.description ?? this.getAttribute("aria-description") ?? void 0;
|
|
124
|
+
}
|
|
71
125
|
updated(changedProperties) {
|
|
72
126
|
super.updated(changedProperties);
|
|
73
127
|
if (this.shouldValidate(changedProperties)) this.setValidity();
|
|
@@ -117,5 +171,6 @@ __decorate([property({ type: String }), __decorateMetadata("design:type", String
|
|
|
117
171
|
__decorate([property({ type: String }), __decorateMetadata("design:type", String)], UFormControlElement.prototype, "description", void 0);
|
|
118
172
|
__decorate([property({ type: String }), __decorateMetadata("design:type", String)], UFormControlElement.prototype, "name", void 0);
|
|
119
173
|
__decorate([property(), __decorateMetadata("design:type", Object)], UFormControlElement.prototype, "value", void 0);
|
|
174
|
+
__decorate([state(), __decorateMetadata("design:type", Object)], UFormControlElement.prototype, "hasSlottedLabel", void 0);
|
|
120
175
|
//#endregion
|
|
121
176
|
export { UFormControlElement };
|
|
@@ -6,6 +6,7 @@ import { Locale } from "../../utilities/Locale.js";
|
|
|
6
6
|
import { styles } from "./UCheckbox.styles.js";
|
|
7
7
|
import { html } from "lit";
|
|
8
8
|
import { customElement, property, query } from "lit/decorators.js";
|
|
9
|
+
import { ifDefined } from "lit/directives/if-defined.js";
|
|
9
10
|
import { live } from "lit/directives/live.js";
|
|
10
11
|
//#region src/components/checkbox/UCheckbox.ts
|
|
11
12
|
var UCheckbox = class UCheckbox extends UFormControlElement {
|
|
@@ -43,6 +44,7 @@ var UCheckbox = class UCheckbox extends UFormControlElement {
|
|
|
43
44
|
<label class="wrapper" part="wrapper">
|
|
44
45
|
<input part="input"
|
|
45
46
|
type="checkbox"
|
|
47
|
+
aria-label=${ifDefined(this.contentAriaLabel)}
|
|
46
48
|
?disabled=${this.disabled || this.readonly}
|
|
47
49
|
?required=${this.required}
|
|
48
50
|
.checked=${live(this.checked)}
|
|
@@ -55,7 +57,7 @@ var UCheckbox = class UCheckbox extends UFormControlElement {
|
|
|
55
57
|
></u-icon>
|
|
56
58
|
</span>
|
|
57
59
|
<span class="label" part="label">
|
|
58
|
-
<slot>${this.label}</slot>
|
|
60
|
+
<slot @slotchange=${this.handleLabelSlotChange}>${this.label}</slot>
|
|
59
61
|
<span class="required" ?hidden=${!this.required}>*</span>
|
|
60
62
|
</span>
|
|
61
63
|
</label>
|
|
@@ -203,8 +203,8 @@ var UDatePicker = class UDatePicker extends UFormControlElement {
|
|
|
203
203
|
role="combobox"
|
|
204
204
|
aria-haspopup="dialog"
|
|
205
205
|
aria-expanded=${this.open}
|
|
206
|
-
aria-label=${ifDefined(this.
|
|
207
|
-
aria-description=${ifDefined(this.
|
|
206
|
+
aria-label=${ifDefined(this.resolvedAriaLabel)}
|
|
207
|
+
aria-description=${ifDefined(this.resolvedAriaDescription)}
|
|
208
208
|
aria-controls=${this.calendarId}
|
|
209
209
|
>
|
|
210
210
|
<span class="text-content ${!displayText ? "placeholder" : ""}">${displayText || this.placeholder || ""}</span>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PropertyValues } from 'lit';
|
|
1
2
|
import { UElement } from '../UElement.js';
|
|
2
3
|
/**
|
|
3
4
|
* 폼 컨트롤의 공통 레이아웃을 제공하는 필드 컴포넌트입니다.
|
|
@@ -33,6 +34,51 @@ export declare class UField extends UElement {
|
|
|
33
34
|
* 공개 API로도 노출해, 소비자가 임의의 폼 컨트롤(자체 웹 컴포넌트가 아닌 것 포함)을
|
|
34
35
|
* 슬롯에 꽂아도 host의 `.focus()`가 `UInput.focus()`와 같은 계약으로 동작하게 한다. */
|
|
35
36
|
focus(options?: FocusOptions): void;
|
|
37
|
+
private get assignedRoots();
|
|
38
|
+
/**
|
|
39
|
+
* 포커스 대상은 **감싸개 안쪽까지** 본다. 이 패키지의 폼 컨트롤들은 자기 내부 `u-field` 에
|
|
40
|
+
* `<div class="container">` 를 슬롯하고 진짜 `<input>` 은 그 안에 있다 — 최상위만 보면
|
|
41
|
+
* *라이브러리 자신의 정상 사용*이 «컨트롤 없음» 이 된다(첫 판이 실제로 그렇게 오탐했다).
|
|
42
|
+
*/
|
|
43
|
+
private get focusTarget();
|
|
44
|
+
/**
|
|
45
|
+
* 라벨이 가리키는 대상 — `focus()` 와 접근성 이름 부여가 **같은 것**을 골라야 한다.
|
|
46
|
+
*
|
|
47
|
+
* 🔴**`isFocusable()` 만으로는 이 리포의 주된 사용 형태를 통째로 놓친다.** `tabbable` 은
|
|
48
|
+
* 기본적으로 섀도우 루트를 들여다보지 않으므로 `<u-input>` 같은 커스텀 엘리먼트 호스트에
|
|
49
|
+
* `false` 를 돌려준다(실측: `isFocusable(u-input)=false` · `isFocusable(<input>)=true`).
|
|
50
|
+
* ⇒ 종전 `focus()` 는 **네이티브 엘리먼트를 슬롯한 경우에만** 동작했고, 레퍼런스 문서가
|
|
51
|
+
* 가르치는 `u-*` 컨트롤 형태에서는 라벨 클릭이 **조용히 아무 일도 하지 않았다.**
|
|
52
|
+
*
|
|
53
|
+
* ⇒ 판정을 «포커스 가능» 하나가 아니라 **«폼 컨트롤인가»** 로 넓힌다: 네이티브로 포커스
|
|
54
|
+
* 가능하거나, **form-associated 커스텀 엘리먼트**(`static formAssociated = true` — 이
|
|
55
|
+
* 패키지의 `UFormControlElement` 전부가 그렇다)이면 대상이다. 섀도우를 파고들어 안쪽
|
|
56
|
+
* 네이티브 노드를 잡지는 않는다 — 그것은 컨트롤 자신의 API 를 우회하는 것이고, 그쪽이
|
|
57
|
+
* 다시 렌더하면 우리가 얹은 속성이 지워진다.
|
|
58
|
+
*/
|
|
59
|
+
private get controlToName();
|
|
60
|
+
protected firstUpdated(changed: PropertyValues): void;
|
|
61
|
+
protected updated(changed: PropertyValues): void;
|
|
62
|
+
/**
|
|
63
|
+
* 🔴**이 컴포넌트의 존재 이유가 «라벨과 컨트롤을 한 칸으로 짝짓는 것»인데, 오랫동안
|
|
64
|
+
* 라벨을 «그리기만» 하고 연결하지 않았다** — 섀도의 `label` 에 `for` 가 없고 슬롯된
|
|
65
|
+
* 컨트롤의 `id`·`aria-label`·`aria-labelledby` 가 전부 빈 채였다 — 라벨을 가진 폼 한 장이
|
|
66
|
+
* 통째로 «이름 없는 입력» 이 된다. 화면은 멀쩡하고 콘솔도 조용해서 **시각 사용자에게는
|
|
67
|
+
* 아무 신호가 없다.**
|
|
68
|
+
*
|
|
69
|
+
* ⚠**`aria-labelledby` 로 잇지 않는다 — 섀도우 경계를 넘지 못한다.** 그 대신 «문자열
|
|
70
|
+
* 복사»를 슬롯 호스트의 `aria-label` 로 얹고, 폼 컨트롤 쪽이 그것을 내부 네이티브
|
|
71
|
+
* 컨트롤로 내려보낸다(`UFormControlElement.resolvedAriaLabel` — `u-button` 이 먼저 채택한
|
|
72
|
+
* 처방을 공통 기반으로 올린 것). 네이티브 엘리먼트를 슬롯한
|
|
73
|
+
* 경우에는 그 호스트가 곧 컨트롤이라 같은 한 줄이 그대로 듣는다.
|
|
74
|
+
*
|
|
75
|
+
* ⚠**자기 이름을 이미 가진 컨트롤은 건드리지 않는다** — 그쪽이 더 구체적이고,
|
|
76
|
+
* 덮으면 눈에 보이는 라벨과 접근성 이름이 어긋난다(WCAG 2.5.3 Label in Name).
|
|
77
|
+
*/
|
|
78
|
+
private nameSlottedControl;
|
|
79
|
+
/** 우리가 얹은 값인지 소비자가 준 값인지 가르기 위한 표식 — 소비자 값을 덮지 않는다. */
|
|
80
|
+
private lastAppliedLabel?;
|
|
81
|
+
private lastAppliedDescription?;
|
|
36
82
|
private handleLabelClick;
|
|
37
83
|
}
|
|
38
84
|
declare global {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { UElement } from "../UElement.js";
|
|
2
2
|
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
|
|
3
3
|
import __decorate from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
|
|
4
|
+
import { devWarnOnce } from "../../utilities/devWarning.js";
|
|
4
5
|
import { styles } from "./UField.styles.js";
|
|
5
6
|
import { html, nothing } from "lit";
|
|
6
7
|
import { customElement, property } from "lit/decorators.js";
|
|
@@ -43,11 +44,89 @@ var UField = class UField extends UElement {
|
|
|
43
44
|
* 공개 API로도 노출해, 소비자가 임의의 폼 컨트롤(자체 웹 컴포넌트가 아닌 것 포함)을
|
|
44
45
|
* 슬롯에 꽂아도 host의 `.focus()`가 `UInput.focus()`와 같은 계약으로 동작하게 한다. */
|
|
45
46
|
focus(options) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
this.focusTarget?.focus(options);
|
|
48
|
+
}
|
|
49
|
+
get assignedRoots() {
|
|
50
|
+
return (this.shadowRoot?.querySelector("slot:not([name])"))?.assignedElements({ flatten: true }) || [];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 포커스 대상은 **감싸개 안쪽까지** 본다. 이 패키지의 폼 컨트롤들은 자기 내부 `u-field` 에
|
|
54
|
+
* `<div class="container">` 를 슬롯하고 진짜 `<input>` 은 그 안에 있다 — 최상위만 보면
|
|
55
|
+
* *라이브러리 자신의 정상 사용*이 «컨트롤 없음» 이 된다(첫 판이 실제로 그렇게 오탐했다).
|
|
56
|
+
*/
|
|
57
|
+
get focusTarget() {
|
|
58
|
+
const named = this.controlToName;
|
|
59
|
+
if (named) return named;
|
|
60
|
+
for (const root of this.assignedRoots) {
|
|
61
|
+
if (isFocusable(root)) return root;
|
|
62
|
+
const inner = Array.from(root.querySelectorAll("*")).find((n) => isFocusable(n));
|
|
63
|
+
if (inner) return inner;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 라벨이 가리키는 대상 — `focus()` 와 접근성 이름 부여가 **같은 것**을 골라야 한다.
|
|
69
|
+
*
|
|
70
|
+
* 🔴**`isFocusable()` 만으로는 이 리포의 주된 사용 형태를 통째로 놓친다.** `tabbable` 은
|
|
71
|
+
* 기본적으로 섀도우 루트를 들여다보지 않으므로 `<u-input>` 같은 커스텀 엘리먼트 호스트에
|
|
72
|
+
* `false` 를 돌려준다(실측: `isFocusable(u-input)=false` · `isFocusable(<input>)=true`).
|
|
73
|
+
* ⇒ 종전 `focus()` 는 **네이티브 엘리먼트를 슬롯한 경우에만** 동작했고, 레퍼런스 문서가
|
|
74
|
+
* 가르치는 `u-*` 컨트롤 형태에서는 라벨 클릭이 **조용히 아무 일도 하지 않았다.**
|
|
75
|
+
*
|
|
76
|
+
* ⇒ 판정을 «포커스 가능» 하나가 아니라 **«폼 컨트롤인가»** 로 넓힌다: 네이티브로 포커스
|
|
77
|
+
* 가능하거나, **form-associated 커스텀 엘리먼트**(`static formAssociated = true` — 이
|
|
78
|
+
* 패키지의 `UFormControlElement` 전부가 그렇다)이면 대상이다. 섀도우를 파고들어 안쪽
|
|
79
|
+
* 네이티브 노드를 잡지는 않는다 — 그것은 컨트롤 자신의 API 를 우회하는 것이고, 그쪽이
|
|
80
|
+
* 다시 렌더하면 우리가 얹은 속성이 지워진다.
|
|
81
|
+
*/
|
|
82
|
+
get controlToName() {
|
|
83
|
+
for (const node of this.assignedRoots) if (node.constructor.formAssociated === true || isFocusable(node)) return node;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
firstUpdated(changed) {
|
|
87
|
+
super.firstUpdated(changed);
|
|
88
|
+
(this.shadowRoot?.querySelector("slot:not([name])"))?.addEventListener("slotchange", () => this.nameSlottedControl());
|
|
89
|
+
}
|
|
90
|
+
updated(changed) {
|
|
91
|
+
super.updated(changed);
|
|
92
|
+
if (changed.has("label") || changed.has("description")) this.nameSlottedControl();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 🔴**이 컴포넌트의 존재 이유가 «라벨과 컨트롤을 한 칸으로 짝짓는 것»인데, 오랫동안
|
|
96
|
+
* 라벨을 «그리기만» 하고 연결하지 않았다** — 섀도의 `label` 에 `for` 가 없고 슬롯된
|
|
97
|
+
* 컨트롤의 `id`·`aria-label`·`aria-labelledby` 가 전부 빈 채였다 — 라벨을 가진 폼 한 장이
|
|
98
|
+
* 통째로 «이름 없는 입력» 이 된다. 화면은 멀쩡하고 콘솔도 조용해서 **시각 사용자에게는
|
|
99
|
+
* 아무 신호가 없다.**
|
|
100
|
+
*
|
|
101
|
+
* ⚠**`aria-labelledby` 로 잇지 않는다 — 섀도우 경계를 넘지 못한다.** 그 대신 «문자열
|
|
102
|
+
* 복사»를 슬롯 호스트의 `aria-label` 로 얹고, 폼 컨트롤 쪽이 그것을 내부 네이티브
|
|
103
|
+
* 컨트롤로 내려보낸다(`UFormControlElement.resolvedAriaLabel` — `u-button` 이 먼저 채택한
|
|
104
|
+
* 처방을 공통 기반으로 올린 것). 네이티브 엘리먼트를 슬롯한
|
|
105
|
+
* 경우에는 그 호스트가 곧 컨트롤이라 같은 한 줄이 그대로 듣는다.
|
|
106
|
+
*
|
|
107
|
+
* ⚠**자기 이름을 이미 가진 컨트롤은 건드리지 않는다** — 그쪽이 더 구체적이고,
|
|
108
|
+
* 덮으면 눈에 보이는 라벨과 접근성 이름이 어긋난다(WCAG 2.5.3 Label in Name).
|
|
109
|
+
*/
|
|
110
|
+
nameSlottedControl() {
|
|
111
|
+
const control = this.controlToName;
|
|
112
|
+
if (!control) {
|
|
113
|
+
if (this.label && !this.focusTarget) devWarnOnce("field-no-control", `u-field label="${this.label}" has no focusable control in its default slot — the label names nothing. Slot a form control, or drop the label.`);
|
|
49
114
|
return;
|
|
50
115
|
}
|
|
116
|
+
if (!!control.label || control.hasAttribute("aria-labelledby") || control.hasAttribute("aria-label") && control.getAttribute("aria-label") !== this.lastAppliedLabel) {
|
|
117
|
+
if (this.label && control.label) devWarnOnce("field-double-label", `u-field label="${this.label}" wraps a control that also sets label="${control.label}" — the label renders twice. Set it on one of them.`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (this.label) {
|
|
121
|
+
control.setAttribute("aria-label", this.label);
|
|
122
|
+
this.lastAppliedLabel = this.label;
|
|
123
|
+
} else if (this.lastAppliedLabel !== void 0) {
|
|
124
|
+
control.removeAttribute("aria-label");
|
|
125
|
+
this.lastAppliedLabel = void 0;
|
|
126
|
+
}
|
|
127
|
+
if (this.description) control.setAttribute("aria-description", this.description);
|
|
128
|
+
else if (control.getAttribute("aria-description") === this.lastAppliedDescription) control.removeAttribute("aria-description");
|
|
129
|
+
this.lastAppliedDescription = this.description;
|
|
51
130
|
}
|
|
52
131
|
};
|
|
53
132
|
__decorate([property({
|
|
@@ -37,6 +37,17 @@ export declare class UFileInput extends UFormControlElement<File[] | null> {
|
|
|
37
37
|
* 넘긴다 — 네이티브 `<input type="file" multiple>`의 제출 형태와 동일하다. */
|
|
38
38
|
protected updated(changedProperties: PropertyValues): void;
|
|
39
39
|
private syncFormValue;
|
|
40
|
+
/**
|
|
41
|
+
* 이 컨트롤의 포커스 대상은 값을 담는 입력이 아니라 **파일 선택 버튼**이다. 그래서 바깥
|
|
42
|
+
* 라벨을 그대로 `aria-label` 로 얹으면 버튼의 보이는 글자("파일 선택")가 접근성 이름에서
|
|
43
|
+
* 사라진다 — WCAG SC 2.5.3 Label in Name 위반이고, 음성 조작 사용자가 보이는 글자로
|
|
44
|
+
* 그 버튼을 부를 수 없게 된다. ⇒ **덮지 않고 합성한다**: 보이는 글자를 **앞에** 두고
|
|
45
|
+
* 라벨을 맥락으로 덧붙인다(이름이 보이는 글자를 담으므로 2.5.3 을 만족하고, 같은 화면에
|
|
46
|
+
* 파일 선택 버튼이 여럿일 때 서로 구분된다).
|
|
47
|
+
*
|
|
48
|
+
* 라벨이 없으면 아무것도 얹지 않는다 — 버튼의 보이는 글자가 곧 이름이다.
|
|
49
|
+
*/
|
|
50
|
+
private get triggerAriaLabel();
|
|
40
51
|
render(): import('lit-html').TemplateResult<1>;
|
|
41
52
|
protected setValidity(): void;
|
|
42
53
|
reset(): void;
|
|
@@ -65,6 +65,20 @@ var UFileInput = class UFileInput extends UFormControlElement {
|
|
|
65
65
|
this.internals?.setFormValue(fd);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* 이 컨트롤의 포커스 대상은 값을 담는 입력이 아니라 **파일 선택 버튼**이다. 그래서 바깥
|
|
70
|
+
* 라벨을 그대로 `aria-label` 로 얹으면 버튼의 보이는 글자("파일 선택")가 접근성 이름에서
|
|
71
|
+
* 사라진다 — WCAG SC 2.5.3 Label in Name 위반이고, 음성 조작 사용자가 보이는 글자로
|
|
72
|
+
* 그 버튼을 부를 수 없게 된다. ⇒ **덮지 않고 합성한다**: 보이는 글자를 **앞에** 두고
|
|
73
|
+
* 라벨을 맥락으로 덧붙인다(이름이 보이는 글자를 담으므로 2.5.3 을 만족하고, 같은 화면에
|
|
74
|
+
* 파일 선택 버튼이 여럿일 때 서로 구분된다).
|
|
75
|
+
*
|
|
76
|
+
* 라벨이 없으면 아무것도 얹지 않는다 — 버튼의 보이는 글자가 곧 이름이다.
|
|
77
|
+
*/
|
|
78
|
+
get triggerAriaLabel() {
|
|
79
|
+
const label = this.resolvedAriaLabel;
|
|
80
|
+
return label ? `${Locale.getValue("chooseFile")}, ${label}` : void 0;
|
|
81
|
+
}
|
|
68
82
|
render() {
|
|
69
83
|
const files = this.value ?? [];
|
|
70
84
|
const hasFiles = files.length > 0;
|
|
@@ -81,6 +95,7 @@ var UFileInput = class UFileInput extends UFormControlElement {
|
|
|
81
95
|
<div class="container" part="container">
|
|
82
96
|
<button class="trigger" part="trigger"
|
|
83
97
|
type="button"
|
|
98
|
+
aria-label=${ifDefined(this.triggerAriaLabel)}
|
|
84
99
|
?disabled=${this.disabled || this.readonly}
|
|
85
100
|
@click=${this.handleTriggerClick}
|
|
86
101
|
>${Locale.getValue("chooseFile")}</button>
|
|
@@ -180,8 +180,8 @@ var UInput = class UInput extends UFormControlElement {
|
|
|
180
180
|
|
|
181
181
|
<input part="input"
|
|
182
182
|
type=${this.type === "password" && this.showPassword ? "text" : this.type}
|
|
183
|
-
aria-label=${ifDefined(this.
|
|
184
|
-
aria-description=${ifDefined(this.
|
|
183
|
+
aria-label=${ifDefined(this.resolvedAriaLabel)}
|
|
184
|
+
aria-description=${ifDefined(this.resolvedAriaDescription)}
|
|
185
185
|
name=${ifDefined(this.name)}
|
|
186
186
|
?required=${this.required}
|
|
187
187
|
?disabled=${this.disabled}
|
|
@@ -93,8 +93,8 @@ var URadio = class URadio extends UFormControlElement {
|
|
|
93
93
|
>
|
|
94
94
|
<div class="container" part="container"
|
|
95
95
|
role="radiogroup"
|
|
96
|
-
aria-label=${ifDefined(this.
|
|
97
|
-
aria-description=${ifDefined(this.
|
|
96
|
+
aria-label=${ifDefined(this.resolvedAriaLabel)}
|
|
97
|
+
aria-description=${ifDefined(this.resolvedAriaDescription)}>
|
|
98
98
|
<slot @slotchange=${this.handleSlotChange}></slot>
|
|
99
99
|
</div>
|
|
100
100
|
</u-field>
|
|
@@ -112,8 +112,8 @@ var URating = class URating extends UFormControlElement {
|
|
|
112
112
|
.validationMessage=${this.validationMessage}
|
|
113
113
|
>
|
|
114
114
|
<div class="symbols" part="container" role="radiogroup"
|
|
115
|
-
aria-label=${ifDefined(this.
|
|
116
|
-
aria-description=${ifDefined(this.
|
|
115
|
+
aria-label=${ifDefined(this.resolvedAriaLabel)}
|
|
116
|
+
aria-description=${ifDefined(this.resolvedAriaDescription)}>
|
|
117
117
|
${Array.from({ length: this.max }, (_, i) => {
|
|
118
118
|
const score = i + 1;
|
|
119
119
|
const fill = Math.max(0, Math.min(1, value - score + 1));
|
|
@@ -173,8 +173,8 @@ var USelect = class USelect extends UFormControlElement {
|
|
|
173
173
|
|
|
174
174
|
<div class="container" part="container" tabindex=${this.disabled ? "-1" : "0"}
|
|
175
175
|
role="combobox"
|
|
176
|
-
aria-label=${ifDefined(this.
|
|
177
|
-
aria-description=${ifDefined(this.
|
|
176
|
+
aria-label=${ifDefined(this.resolvedAriaLabel)}
|
|
177
|
+
aria-description=${ifDefined(this.resolvedAriaDescription)}
|
|
178
178
|
aria-haspopup=${this.searchable ? "dialog" : "listbox"}
|
|
179
179
|
aria-expanded=${this.open}
|
|
180
180
|
aria-controls=${this.searchable ? this.popupId : this.listboxId}>
|
|
@@ -209,7 +209,7 @@ var USelect = class USelect extends UFormControlElement {
|
|
|
209
209
|
<u-popover part="popover"
|
|
210
210
|
id=${this.popupId}
|
|
211
211
|
role=${ifDefined(this.searchable ? "dialog" : void 0)}
|
|
212
|
-
aria-label=${ifDefined(this.searchable ? this.
|
|
212
|
+
aria-label=${ifDefined(this.searchable ? this.resolvedAriaLabel || Locale.getValue("search") : void 0)}
|
|
213
213
|
scrollable
|
|
214
214
|
autofocus
|
|
215
215
|
for=".container"
|
|
@@ -230,7 +230,7 @@ var USlider = class USlider extends UFormControlElement {
|
|
|
230
230
|
return html`
|
|
231
231
|
<div class="thumb" part=${isEnd ? "thumb-end" : "thumb"}
|
|
232
232
|
role="slider"
|
|
233
|
-
aria-label=${ifDefined(this.
|
|
233
|
+
aria-label=${ifDefined(this.resolvedAriaLabel)}
|
|
234
234
|
aria-valuenow=${val}
|
|
235
235
|
aria-valuemin=${this.min}
|
|
236
236
|
aria-valuemax=${this.max}
|
|
@@ -2,6 +2,7 @@ import { UElement } from "../UElement.js";
|
|
|
2
2
|
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
|
|
3
3
|
import __decorate from "../../_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
|
|
4
4
|
import { arrayAttrConverter } from "../../utilities/converters.js";
|
|
5
|
+
import { devWarnOnce } from "../../utilities/devWarning.js";
|
|
5
6
|
import { Locale } from "../../utilities/Locale.js";
|
|
6
7
|
import { styles } from "./USplitPanel.styles.js";
|
|
7
8
|
import { html } from "lit";
|
|
@@ -247,7 +248,14 @@ var USplitPanel = class USplitPanel extends UElement {
|
|
|
247
248
|
* 실측 18px. 호스트가 `overflow: hidden` 이라 패널 내용에 닿을 방법이 없고,
|
|
248
249
|
* 오류도 없다. 첫 배치 뒤 한 번 재서 개발 모드에서 알린다.
|
|
249
250
|
*/
|
|
250
|
-
warnIfCollapsed() {
|
|
251
|
+
warnIfCollapsed() {
|
|
252
|
+
if (process.env.NODE_ENV === "production" || this.panels.length === 0 || !this.isConnected) return;
|
|
253
|
+
{
|
|
254
|
+
const height = this.getBoundingClientRect().height;
|
|
255
|
+
if (height >= 40) return;
|
|
256
|
+
devWarnOnce(`split-panel:${this.id || "height"}`, `u-split-panel is only ${Math.round(height)}px tall — it has no height of its own (its height: 100% found no sized parent), so its panels take one line of content and anything sized 100% inside them collapses to nothing. Give the host or its parent a height (e.g. height: 400px, or flex: 1 1 auto inside a sized parent).`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
251
259
|
appendSplitters() {
|
|
252
260
|
const root = this.renderRoot;
|
|
253
261
|
if (!root) return;
|
|
@@ -5,6 +5,7 @@ import { Locale } from "../../utilities/Locale.js";
|
|
|
5
5
|
import { styles } from "./USwitch.styles.js";
|
|
6
6
|
import { html } from "lit";
|
|
7
7
|
import { customElement, property, query } from "lit/decorators.js";
|
|
8
|
+
import { ifDefined } from "lit/directives/if-defined.js";
|
|
8
9
|
import { live } from "lit/directives/live.js";
|
|
9
10
|
//#region src/components/switch/USwitch.ts
|
|
10
11
|
var USwitch = class USwitch extends UFormControlElement {
|
|
@@ -38,6 +39,7 @@ var USwitch = class USwitch extends UFormControlElement {
|
|
|
38
39
|
<label class="wrapper" part="wrapper">
|
|
39
40
|
<input
|
|
40
41
|
type="checkbox"
|
|
42
|
+
aria-label=${ifDefined(this.contentAriaLabel)}
|
|
41
43
|
?disabled=${this.disabled || this.readonly}
|
|
42
44
|
?required=${this.required}
|
|
43
45
|
.checked=${live(this.checked)}
|
|
@@ -60,7 +62,13 @@ var USwitch = class USwitch extends UFormControlElement {
|
|
|
60
62
|
</span>
|
|
61
63
|
</span>
|
|
62
64
|
<span class="label" part="label">
|
|
63
|
-
|
|
65
|
+
<!-- 형제 u-checkbox 는 슬롯 폴백으로 label 을 그리는데 여기만 없었다 — 상속받은
|
|
66
|
+
공개 프로퍼티(UFormControlElement.label)를 이 컴포넌트만 한 번도 읽지 않아
|
|
67
|
+
u-switch 에 label 속성을 주면 눈에 보이는 라벨도 접근성 이름도 생기지 않았다
|
|
68
|
+
문서는 기본 슬롯만 가르쳐 왔으므로 문서 위반이
|
|
69
|
+
아니라 «선언은 있는데 동작이 없는» 자리다.
|
|
70
|
+
주의: 이 주석에 백틱을 쓰지 말 것 — html 태그드 템플릿을 그 자리에서 끝낸다. -->
|
|
71
|
+
<slot @slotchange=${this.handleLabelSlotChange}>${this.label}</slot>
|
|
64
72
|
<span class="required" ?hidden=${!this.required}>*</span>
|
|
65
73
|
</span>
|
|
66
74
|
</label>
|
|
@@ -56,8 +56,8 @@ var UTextarea = class UTextarea extends UFormControlElement {
|
|
|
56
56
|
<div class="container" part="container">
|
|
57
57
|
<textarea part="textarea"
|
|
58
58
|
scrollable
|
|
59
|
-
aria-label=${ifDefined(this.
|
|
60
|
-
aria-description=${ifDefined(this.
|
|
59
|
+
aria-label=${ifDefined(this.resolvedAriaLabel)}
|
|
60
|
+
aria-description=${ifDefined(this.resolvedAriaDescription)}
|
|
61
61
|
?required=${this.required}
|
|
62
62
|
?disabled=${this.disabled}
|
|
63
63
|
?readonly=${this.readonly}
|
|
@@ -41,7 +41,7 @@ export declare const MIN_STEP_SEPARATION = 1.2;
|
|
|
41
41
|
export declare function parseColor(value: string): [number, number, number] | null;
|
|
42
42
|
export declare function toHex([r, g, b]: [number, number, number]): string;
|
|
43
43
|
export declare function luminance(color: string): number;
|
|
44
|
-
/** WCAG 2.
|
|
44
|
+
/** WCAG 2.x 명암비(상대 휘도 공식 — 2.2 에서 바뀌지 않았다). */
|
|
45
45
|
export declare function contrast(a: string, b: string): number;
|
|
46
46
|
/** `color-mix(in srgb, a p%, b)` 와 같은 계산. */
|
|
47
47
|
export declare function mix(a: string, b: string, p: number): string;
|
package/dist/utilities/accent.js
CHANGED
|
@@ -39,7 +39,7 @@ function luminance(color) {
|
|
|
39
39
|
const [r, g, b] = rgb.map((v) => channelLum(v / 255));
|
|
40
40
|
return .2126 * r + .7152 * g + .0722 * b;
|
|
41
41
|
}
|
|
42
|
-
/** WCAG 2.
|
|
42
|
+
/** WCAG 2.x 명암비(상대 휘도 공식 — 2.2 에서 바뀌지 않았다). */
|
|
43
43
|
function contrast(a, b) {
|
|
44
44
|
const [x, y] = [luminance(a), luminance(b)];
|
|
45
45
|
return (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
|
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
* 개발 모드 «사용 안내» 경고 — 오류 경로가 아닌, ***조용히 틀리는*** 상태를 한 번 알린다.
|
|
3
3
|
*
|
|
4
4
|
* 이 패키지에는 같은 모양의 경고가 이미 있다(`UElement.warnIfTokensMissing` — 토큰 시트 부재).
|
|
5
|
-
* 공통 규약: `[<패키지>]` 네임스페이스 · 개발 모드에서만(`
|
|
5
|
+
* 공통 규약: `[<패키지>]` 네임스페이스 · 개발 모드에서만(`process.env.NODE_ENV !== 'production'`) ·
|
|
6
6
|
* 같은 키로는 **한 번만** · 무엇이 틀렸는지가 아니라 **무엇을 하면 되는지**를 적는다.
|
|
7
7
|
*
|
|
8
|
+
* ⚠**`import.meta.env.DEV` 를 쓰지 않는다** — Vite 는 이 라이브러리 **자신의** `vite build`
|
|
9
|
+
* 시점에 그 값을 정적으로 `false` 로 치환하고, Rollup 의 dead-code elimination 이 이후
|
|
10
|
+
* 분기를 통째로 지운다. 소비자가 dev 서버로 띄워도 이미 게시본에서 죽은 코드라 살아나지
|
|
11
|
+
* 않는다(2026-09-13 실측 — 1.40.1·1.40.2 게시본에서 이 경고가 전부 no-op 이었다).
|
|
12
|
+
* `process.env.NODE_ENV` 는 이 라이브러리의 빌드가 손대지 않고 그대로 dist 에 남아,
|
|
13
|
+
* **소비자 자신의** 번들러가 소비자의 빌드 시점에 치환한다(React 등 생태계 라이브러리와
|
|
14
|
+
* 동일한 관례) — `tests/build/dev-warning-dist.test.ts` 가 게시본에 대해 이 계약을 지킨다.
|
|
15
|
+
*
|
|
8
16
|
* 이 파일이 생긴 계기: 소비앱의 메뉴 30개가 해석되지 않는 아이콘 이름으로 **한꺼번에 같은
|
|
9
17
|
* 큐브 폴백**을 그렸는데 신호가 0 이었다(소비자 실측). 폴백은 의도된 것이라(접힌 사이드바에서
|
|
10
18
|
* 아이콘이 없으면 메뉴가 «높이만 있는 빈 줄» 이 된다) 제거가 답이 아니다 — **없는 것은 경고다.**
|
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
* 개발 모드 «사용 안내» 경고 — 오류 경로가 아닌, ***조용히 틀리는*** 상태를 한 번 알린다.
|
|
4
4
|
*
|
|
5
5
|
* 이 패키지에는 같은 모양의 경고가 이미 있다(`UElement.warnIfTokensMissing` — 토큰 시트 부재).
|
|
6
|
-
* 공통 규약: `[<패키지>]` 네임스페이스 · 개발 모드에서만(`
|
|
6
|
+
* 공통 규약: `[<패키지>]` 네임스페이스 · 개발 모드에서만(`process.env.NODE_ENV !== 'production'`) ·
|
|
7
7
|
* 같은 키로는 **한 번만** · 무엇이 틀렸는지가 아니라 **무엇을 하면 되는지**를 적는다.
|
|
8
8
|
*
|
|
9
|
+
* ⚠**`import.meta.env.DEV` 를 쓰지 않는다** — Vite 는 이 라이브러리 **자신의** `vite build`
|
|
10
|
+
* 시점에 그 값을 정적으로 `false` 로 치환하고, Rollup 의 dead-code elimination 이 이후
|
|
11
|
+
* 분기를 통째로 지운다. 소비자가 dev 서버로 띄워도 이미 게시본에서 죽은 코드라 살아나지
|
|
12
|
+
* 않는다(2026-09-13 실측 — 1.40.1·1.40.2 게시본에서 이 경고가 전부 no-op 이었다).
|
|
13
|
+
* `process.env.NODE_ENV` 는 이 라이브러리의 빌드가 손대지 않고 그대로 dist 에 남아,
|
|
14
|
+
* **소비자 자신의** 번들러가 소비자의 빌드 시점에 치환한다(React 등 생태계 라이브러리와
|
|
15
|
+
* 동일한 관례) — `tests/build/dev-warning-dist.test.ts` 가 게시본에 대해 이 계약을 지킨다.
|
|
16
|
+
*
|
|
9
17
|
* 이 파일이 생긴 계기: 소비앱의 메뉴 30개가 해석되지 않는 아이콘 이름으로 **한꺼번에 같은
|
|
10
18
|
* 큐브 폴백**을 그렸는데 신호가 0 이었다(소비자 실측). 폴백은 의도된 것이라(접힌 사이드바에서
|
|
11
19
|
* 아이콘이 없으면 메뉴가 «높이만 있는 빈 줄» 이 된다) 제거가 답이 아니다 — **없는 것은 경고다.**
|
|
@@ -21,7 +29,13 @@ var warned = /* @__PURE__ */ new Set();
|
|
|
21
29
|
* 프로덕션 빌드에서는 호출 자체가 no-op 이다.
|
|
22
30
|
*/
|
|
23
31
|
function createDevWarner(scope) {
|
|
24
|
-
return (key, message) => {
|
|
32
|
+
return (key, message) => {
|
|
33
|
+
if (process.env.NODE_ENV === "production") return;
|
|
34
|
+
const scoped = `${scope} ${key}`;
|
|
35
|
+
if (warned.has(scoped)) return;
|
|
36
|
+
warned.add(scoped);
|
|
37
|
+
console.warn(`[${scope}] ${message}`);
|
|
38
|
+
};
|
|
25
39
|
}
|
|
26
40
|
/** `key` 당 한 번만, 개발 모드에서만 `[@iyulab/components]` 로 `console.warn` 한다. */
|
|
27
41
|
var devWarnOnce = createDevWarner("@iyulab/components");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iyulab/components",
|
|
3
3
|
"description": "web-components library based on lit-element made by iyulab",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.41.0",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iyulab",
|
|
7
7
|
"components",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"@eslint/js": "^10.0.1",
|
|
87
87
|
"@types/node": "^26.1.1",
|
|
88
88
|
"@types/react": "^19.2.14",
|
|
89
|
-
"@vitest/browser-playwright": "^
|
|
89
|
+
"@vitest/browser-playwright": "^5.0.0",
|
|
90
90
|
"eslint": "^10.1.0",
|
|
91
91
|
"eslint-plugin-lit": "^2.3.1",
|
|
92
92
|
"globals": "^17.7.0",
|
|
@@ -97,6 +97,6 @@
|
|
|
97
97
|
"vite": "^8.1.4",
|
|
98
98
|
"vite-plugin-dts": "^5.0.3",
|
|
99
99
|
"vite-plugin-static-copy": "^4.1.1",
|
|
100
|
-
"vitest": "^
|
|
100
|
+
"vitest": "^5.0.0"
|
|
101
101
|
}
|
|
102
102
|
}
|
|
@@ -23,6 +23,43 @@ Layout wrapper for form controls. Renders label, required marker, description te
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
+
## Accessible name
|
|
27
|
+
|
|
28
|
+
`u-field` does more than draw the label — it **names the control you slot into it**. The label text
|
|
29
|
+
is copied onto the slotted element as `aria-label` (and `description` as `aria-description`), and
|
|
30
|
+
`u-*` form controls pass that through to the native control inside their shadow root.
|
|
31
|
+
`aria-labelledby` is not used: it does not cross shadow boundaries.
|
|
32
|
+
|
|
33
|
+
**Set the label in one place only.** If the slotted control already carries its own `label`,
|
|
34
|
+
`aria-label`, or `aria-labelledby`, the field leaves it alone — overriding it would make the visible
|
|
35
|
+
label and the accessible name disagree (WCAG SC 2.5.3 Label in Name). Setting `label` on *both*
|
|
36
|
+
renders the label twice; a development-mode console warning points at that spot.
|
|
37
|
+
|
|
38
|
+
```html
|
|
39
|
+
<!-- label on the field -->
|
|
40
|
+
<u-field label="Email"><u-input type="email" name="email"></u-input></u-field>
|
|
41
|
+
|
|
42
|
+
<!-- or on the control — not both -->
|
|
43
|
+
<u-input label="Email" type="email" name="email"></u-input>
|
|
44
|
+
|
|
45
|
+
<!-- native elements work the same way -->
|
|
46
|
+
<u-field label="Notes"><textarea name="notes"></textarea></u-field>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A `u-field` that has a `label` but nothing focusable in its default slot also warns: that label
|
|
50
|
+
names nothing.
|
|
51
|
+
|
|
52
|
+
All ten form controls take the field's label this way. Three of them get it slightly differently,
|
|
53
|
+
because their name normally comes from their own content:
|
|
54
|
+
|
|
55
|
+
| Control | How the field label reaches it |
|
|
56
|
+
|---------|-------------------------------|
|
|
57
|
+
| `u-checkbox`, `u-switch` | Only when they have no label text of their own (own `label`, or content in the default slot). Their own text always wins. |
|
|
58
|
+
| `u-file-input` | Composed into the trigger button's name as `"<button text>, <field label>"`, so the visible text stays part of the accessible name. |
|
|
59
|
+
| everything else | Directly, on the native control inside the shadow root. |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
26
63
|
## Slots
|
|
27
64
|
|
|
28
65
|
| Name | Description |
|
|
@@ -25,7 +25,7 @@ Toggle switch (on/off). Supports custom track and thumb content via slots. Form-
|
|
|
25
25
|
|
|
26
26
|
| Name | Description |
|
|
27
27
|
|------|-------------|
|
|
28
|
-
| *(default)* | Label text |
|
|
28
|
+
| *(default)* | Label text (falls back to the `label` property) |
|
|
29
29
|
| `track-checked` | Content inside the track when checked |
|
|
30
30
|
| `track-unchecked` | Content inside the track when unchecked |
|
|
31
31
|
| `thumb-checked` | Thumb content when checked |
|
|
@@ -35,6 +35,7 @@ Toggle switch (on/off). Supports custom track and thumb content via slots. Form-
|
|
|
35
35
|
|
|
36
36
|
| Property | Type | Default | Reflect | Description |
|
|
37
37
|
|----------|------|---------|---------|-------------|
|
|
38
|
+
| `label` | `string` | — | — | Label text — fallback for the default slot |
|
|
38
39
|
| `checked` | `boolean` | `false` | ✓ | On/off state |
|
|
39
40
|
| `disabled` | `boolean` | `false` | ✓ | Disable |
|
|
40
41
|
| `readonly` | `boolean` | `false` | ✓ | Read-only |
|