@iyulab/components 패키지에 컴포넌트를 추가하거나 수정할 때 따라야 하는 규칙과 패턴입니다.
src/components/<name>/
├── U<Name>.ts # 컴포넌트 클래스
└── U<Name>.styles.ts # 스타일 전용
- 디렉터리 이름:
kebab-case(예:button-group,tree-item) - 파일 이름:
PascalCase+U접두사 (예:UButtonGroup.ts) - 스타일 파일은 CSS 리터럴만 포함하고 로직을 넣지 않는다.
index.ts는 사용하지 않는다.src/index.ts에서 직접 export.
| 상황 | 상속 클래스 |
|---|---|
| 일반 UI 요소 | UElement |
| 사용자 입력 / 폼 필드 | UFormControlElement<T> |
| 앵커 기반 플로팅 패널 | UFloatingElement |
| 모달 오버레이 | UOverlayElement |
| JSON 데이터 기반 | UDataElement |
스타일 상속은 반드시 static styles = [super.styles, styles] 형태로 작성한다.
클래스 JSDoc은 한국어로 작성하며, 다음 태그를 명시한다.
/**
* 컴포넌트 한 줄 설명.
*
* @slot - 기본 슬롯
* @slot prefix - 앞쪽 슬롯
*
* @csspart container - 외부 래퍼
*
* @cssprop --my-widget-color - 텍스트 색상
*
* @event pick - 항목이 선택됐을 때 발생
* @event change - 값이 확정됐을 때 발생
*//** 버튼 스타일 변형 */
@property({ type: String, reflect: true }) variant: ButtonVariant = 'solid';
/** 비활성 상태 */
@property({ type: Boolean, reflect: true }) disabled = false;
/** 링크 URL */
@property({ type: String }) href?: string;- 각 프로퍼티 위에 한 줄 JSDoc 주석 작성.
variant,disabled,open,loading등 HTML 속성으로 관찰 가능해야 하는 값에는reflect: true사용.- 내부 상태만 반영하는 서술형 값(
href,label등)에는reflect생략. - 타입 유니언은 파일 상단에
export type으로 분리해 선언.
파일 끝에 반드시 HTMLElementTagNameMap 선언을 추가한다.
declare global {
interface HTMLElementTagNameMap {
'u-my-widget': UMyWidget;
}
}내부 모듈 import 시 .js 확장자를 명시한다.
import { UElement } from '../UElement.js';
import { styles } from './UMyWidget.styles.js';src/events/에 정의된 이벤트를 우선 사용한다.
| 이벤트 | 상황 |
|---|---|
show / hide |
표시/숨김 전환 |
pick |
항목 선택 (선택 상태 포함) |
change |
값 확정 (blur, enter) |
input |
실시간 입력값 변경 |
navigate |
라우팅 이동 |
remove |
항목 삭제 |
check |
체크 상태 변경 |
expand / collapse |
트리/아코디언 열기/닫기 |
shift |
드래그/정렬 이동 |
기존 이벤트 타입으로 표현하기 어려울 때만 src/events/에 새 파일을 추가한다.
// src/events/MyEvent.ts
export interface MyEventDetail {
value: string;
}
export type MyEvent = CustomEvent<MyEventDetail>;
declare global {
interface GlobalEventHandlersEventMap {
'my-event': MyEvent;
}
}커스텀 이벤트는 fire() 메서드를 사용한다.
네이티브 이벤트 전달은 relay()를 사용한다.
// 커스텀 이벤트
this.fire<ShowEventDetail>('show');
// detail 포함
this.fire<PickEventDetail>('pick', {
detail: { value: this.value, selected: true, shiftKey: false, metaKey: false, ctrlKey: false }
});
// 네이티브 이벤트 전달
this.relay(event);dispatchEvent(new CustomEvent(...)) 를 직접 호출하지 않는다.
UFormControlElement<T>를 상속할 때는 setValidity()와 reset()을 반드시 구현한다. validate()는 기반 클래스가 공통 제공하므로 override하지 않는다.
@customElement('u-my-input')
export class UMyInput extends UFormControlElement<string> {
static styles = [super.styles, styles];
render() {
return html`
<input
.value=${this.value ?? ''}
?disabled=${this.disabled}
?readonly=${this.readonly}
@input=${(e: Event) => {
this.value = (e.target as HTMLInputElement).value;
this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
}}
@change=${(e: Event) => {
this.value = (e.target as HTMLInputElement).value;
this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
}}
/>
`;
}
protected setValidity(): void {
const missing = this.required && !this.value;
this.commit(
missing ? { valueMissing: true } : {},
missing ? Locale.getValue('valueMissing') : '',
this.containerEl ?? undefined, // @query로 얻은 앵커. null일 수 있으니 항상 `?? undefined`로 넘긴다.
);
}
reset(): void {
this.value = undefined;
this.invalid = false;
}
}setValidity()는 지금 상태를 검증해this.commit(flags, message, anchor)를 호출한다.flags는 네이티브ValidityStateFlags를 그대로 쓰고,message는Locale.getValue(key, params)로 조회한다(항상 전역 활성 로케일 기준).internals.setValidity()를 직접 부르지 않는다 —commit()이setCustomValidity()로 주입된 커스텀 메시지가 있는지 먼저 확인하고 있으면 그걸 최우선으로 반영한다.UFormControlElement.validationMessage는 읽기 전용 getter로,internals.validationMessage(즉setValidity()가 마지막으로 넘긴 문자열)를 그대로 반환한다. 별도로 저장하는 상태가 없다 —render()에서<u-field .validationMessage=${this.validationMessage}>처럼 바로 바인딩하면 된다.- 이 getter는 Lit 리액티브 속성이 아니므로,
internals.validationMessage만 바뀌고invalid가true→true로 그대로면(예:invalid는 유지된 채 로케일만 바뀌어 문구가 달라지는 경우) 재렌더가 안 일어나 화면이 갱신되지 않는다. 그래서 기반 클래스의updated()/validate()는setValidity()직후this.requestUpdate()를 호출해 강제로 재렌더한다 — 새 필드를 추가하는 대신 이미 있는 Lit API로 리액티브 갭을 메운 것.
- 이 getter는 Lit 리액티브 속성이 아니므로,
- anchor 인자는 항상
?? undefined로 넘긴다. Lit@query의 TS 타입은T | undefined라고 선언돼 있지만 실제로 못 찾으면null을 반환한다.internals.setValidity(flags, message, null)은HTMLElement가 아니라며 런타임에TypeError를 던진다 — 컴파일은 통과하니 놓치기 쉽다. value가 바뀌면 기반 클래스의updated()가 자동으로setValidity()를 호출한다 — 컴포넌트가 change/blur 핸들러에서 직접 호출할 필요는 없다.checked처럼value외의 속성으로 상태를 표현하는 컴포넌트는protected shouldValidate(changed)를 override한다:return super.shouldValidate(changed) || changed.has('checked');novalidate가false일 때 값이 변경되면 자동으로validate()가 호출되어invalid를 갱신한다.validate()는 내부적으로setValidity()를 먼저 호출해 최신 상태를 반영한 뒤internals.checkValidity()로 판정한다.validate(report = true):report가false면 반환값(유효 여부)만 조용히 확인하고invalid/화면 표시는 건드리지 않는다 — 네이티브checkValidity()(조용히 확인) vsreportValidity()(UI 갱신)와 같은 관계. 예: 제출 버튼 활성화 여부를 매 입력마다 확인하되, 에러 표시는 실제 제출 시도 시에만 하고 싶을 때field.validate(false)로 미리 확인한다.
네이티브 HTMLInputElement.setCustomValidity()와 동일한 계약이다.
const input = document.querySelector('u-input')!;
input.setCustomValidity('이미 사용 중인 아이디입니다.');
input.validate(); // 이 시점에 비로소 invalid=true + 화면에 메시지가 표시된다setCustomValidity(message)는 상태(internals)만 갱신하고 화면에는 아무 영향을 주지 않는다. 빈 문자열이 아니면 저장해두고, 다음setValidity()호출(자동이든validate()를 통해서든) 때부터commit()이 이 메시지를 다른 모든 검증 결과보다 우선해 반영한다.invalid를 화면에 반영하는 건 언제나validate()의 몫이다 —setCustomValidity()를 부르는 것만으로는 아무것도 안 보인다. 스스로 화면에 드러내고 싶으면 이어서validate()를 호출한다.- 빈 문자열(
'')을 넘기면 커스텀 메시지가 해제되어 원래 자동 계산된 메시지(네이티브 제약 또는Locale)로 돌아간다.
검증 메시지는 하드코딩하지 않고 Locale 유틸리티(src/utilities/Locale.ts)를 경유한다.
en/ko/ja/zh-CN/zh-TW/es/fr/de/pt-BR/vi/th/id/ru/ar14개 로케일은src/assets/locales/*.json으로 빌드 시점에 내장된다. 새 메시지 키는LocaleMessageKey에 추가하고 모든 JSON 파일에 반영한다.- 활성 로케일은 초기에
document.documentElement.lang→navigator.language순으로 자동 추측되고(브라우저 환경), 둘 다 없으면 영어로 폴백한다.<html lang>이 먼저인 이유는 그것이 문서 언어에 대한 저자의 선언이고 보조기술이 발음 규칙을 고르는 근거이기 때문이다(WCAG 3.1.1/3.1.2) —navigator.language는 그 선언이 없을 때의 사용자 선호 폴백이다.Locale.set()으로 언제든 명시적으로 바꿀 수 있다. - 메시지 조회 순서:
Locale.getValue()가 찾는 값(활성 로케일 → 접두를 줄인 태그 → 그 언어의 기본 지역형 → 영어). 기본 지역형은 내장 표가 지역형으로만 있는 언어(zh·pt)를 위한 것으로REGIONAL_DEFAULTS에 명시한다 — 중국어는 표기 체계가 둘이라 «첫 번째 표» 로 고르지 않는다. - 그 외 언어나 문구 오버라이드는
Locale.register()로 등록한다. 이미 있는 값(내장 포함) 위에 병합되므로 일부 키만 넘겨도 나머지 키는 그대로 유지된다.
import { Locale } from '@iyulab/components';
Locale.register('de', { valueMissing: 'Pflichtfeld' }); // 미내장 언어 등록 (부분 등록 가능)
Locale.set('ko'); // 앱 전역 활성 로케일 지정src/index.ts에 컴포넌트와 이벤트를 추가한다.
// Components 섹션 (알파벳 순서 유지)
export * from './components/my-widget/UMyWidget.js';
// Events 섹션 (신규 이벤트 추가 시)
export * from './events/MyEvent';컴포넌트 추가 후 다음 파일을 함께 업데이트한다.
skills/iyulab-components/references/components/my-widget.md— 컴포넌트 레퍼런스 파일 추가skills/iyulab-components/SKILL.md— 컴포넌트 목록에 항목 추가
- Shadow DOM을 유지한다.
createRenderRoot()로 Shadow DOM을 우회하지 않는다. - 여백(margin)·테두리(border)·색상(color)·글자 크기(font-size)·배경(background)등을
:host에 위치시켜 소비앱이u-button { padding: 8px; color: red; }처럼 커스텀 엘리먼트 태그를 직접 셀렉터로 잡아 손쉽게 커스터마이징할 수 있게 의도 한다. - 슬롯 이름은
prefix/suffix/footer등 역할 기반으로 통일한다. - CSS part 이름은 내부 구조를 반영하되 구현 세부사항을 노출하지 않는다.
- 다른 컴포넌트를 내부에서 사용할 때는 파일 상단에 사이드이펙트 import를 추가한다.
import '../spinner/USpinner.js';