From 7304aafc839405d363e5c1e911ddac0b910fdb99 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sat, 8 Aug 2026 12:51:51 +0900 Subject: [PATCH] feat: standardize FieldShell accessibility boundary (AEG-V16-017) Centralize label, error, help, required, and ARIA relationships across core field wrappers. Preserve WBS evidence and keep the item IN_PROGRESS pending predecessor acceptance evidence. Evidence: frontend pnpm typecheck; pnpm test (42/42); pnpm build. --- .../AEG-V16-017_FIELDSHELL_SLICE_NOTE.md | 27 ++++ .../CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv | 1 + .../src/shared/ui/components/FieldShell.vue | 2 +- .../shared/ui/components/FieldShell.vue.js | 3 +- .../src/shared/ui/components/KsDateField.vue | 7 +- .../shared/ui/components/KsDateField.vue.js | 128 +++++++++-------- .../shared/ui/components/KsNumberField.vue | 7 +- .../shared/ui/components/KsNumberField.vue.js | 136 +++++++++--------- .../src/shared/ui/components/KsSelect.vue | 13 +- .../src/shared/ui/components/KsSelect.vue.js | 125 ++++++++-------- .../src/shared/ui/components/KsTextArea.vue | 13 +- .../shared/ui/components/KsTextArea.vue.js | 125 ++++++++-------- .../src/shared/ui/components/KsTextField.vue | 23 +-- .../shared/ui/components/KsTextField.vue.js | 124 ++++++++-------- .../ui/components/tests/FieldShell.spec.js | 36 +++++ .../ui/components/tests/FieldShell.spec.ts | 40 ++++++ 16 files changed, 458 insertions(+), 352 deletions(-) create mode 100644 docs/CURRENT/AEG-V16-017_FIELDSHELL_SLICE_NOTE.md create mode 100644 frontend/src/shared/ui/components/tests/FieldShell.spec.js create mode 100644 frontend/src/shared/ui/components/tests/FieldShell.spec.ts diff --git a/docs/CURRENT/AEG-V16-017_FIELDSHELL_SLICE_NOTE.md b/docs/CURRENT/AEG-V16-017_FIELDSHELL_SLICE_NOTE.md new file mode 100644 index 00000000..ab513744 --- /dev/null +++ b/docs/CURRENT/AEG-V16-017_FIELDSHELL_SLICE_NOTE.md @@ -0,0 +1,27 @@ +# AEG-V16-017 — FieldShell standardization + +## Scope + +- **WBS / Requirement / UI / Test:** AEG-V16-017 / REQ-V16-FEC-01 / UI-V16-FEC-01 / T-V16-FEC-01 +- **Classification:** one FE component vertical slice; no API, database, policy, provider, or design-token contract change. +- **Target gate:** MVP-A. This note records implementation evidence only; it does not claim the gate is passed. + +## Source / Assumption / Unknown / Decision Required + +- **Source:** `docs/CURRENT/CATALOGS/WBS_MASTER.csv`, `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv`, `contracts/ui/ui-adapter.v4.json`, and the existing components under `frontend/src/shared/ui/components/`. +- **Assumption:** the v4 provider adapters preserve fall-through ARIA attributes on their concrete input control. This is characterized by the shared-component test; provider-level evidence remains owned by AEG-V16-013 and AEG-V16-066. +- **Unknown:** the tracker has no rows for AEG-V16-009 through AEG-V16-016, while the Master marks them PLANNED and their source artifacts exist. Their acceptance evidence has not been reconstructed in this slice. +- **Decision Required:** a WBS owner must reconcile the Master and progress tracker and attach/approve the AEG-V16-016 vendor-boundary evidence before AEG-V16-017 can be marked COMPLETED. + +## Acceptance mapping + +| Acceptance requirement | Implementation evidence | +| --- | --- | +| label/error/help/ARIA single boundary | `FieldShell.vue` owns IDs, label, required indicator, invalid state, and message relationship. | +| no repeated field chrome in feature components | text, textarea, select, date, and number wrappers render `FieldShell`. | +| reproducible regression check | `FieldShell.spec.ts`, `pnpm typecheck`, and `pnpm test` result are recorded in the tracker after execution. | + +## Non-goals + +- Provider selection, capability changes, visual approval, and runtime API behavior. +- Multi-select and checkbox semantics: their v4 provider contracts do not yet expose the normalized field-control props needed for a safe conversion. diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv index 8282827d..495f2882 100644 --- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv +++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv @@ -6,6 +6,7 @@ AEG-X-004,S0,Cross,DbUp 복구 rehearsal 고도화,COMPLETED,2026-08-06,"docs/CU AEG-X-005,S0,Cross,Security auth 고도화,COMPLETED,2026-08-04,"docs/decisions/ADR-SEC-001.md + tests/KArtSell.Integration.Tests/SecurityAuthenticationTests.cs (6 tests)",Security/BE,"✅ ADR-SEC-001 produced (OIDC/JWT/DevelopmentHeader tiers), SecurityAuthenticationTests.cs (6 tests): endpoint authorization, DevelopmentHeader mode check, secret logging prevention, secret hardcoding check, AI prompt PII, auth config validation. Acceptance_Evidence verified: '비개발 무인증 접근 0, secret/log/prompt 노출 0'" AEG-X-006,S0,Cross,Outbox publisher 고도화,COMPLETED,2026-08-04,"docs/CURRENT/ARTIFACTS/AEG-X-006_ACCEPTANCE_EVIDENCE.md + src/KArtSell.BuildingBlocks/Reliability/DapperOutboxWriter.cs + OutboxPollerJob.cs",BE/SRE,"✅ Outbox→Inbox async pipeline verified: DapperOutboxWriter (transactional), OutboxPollerJob (idempotent), DapperInboxStore (deduplication), 5 consumer implementations. Acceptance_Evidence: All criteria met. 177/177 tests PASS." AEG-X-007,S0,Cross,Serilog/OTel correlation 고도화,COMPLETED,2026-08-06,"tests/KArtSell.ArchitectureTests/PiiRedactionTests.cs (6 tests) + commit e7913db",SRE/Security,"✅ PII redaction policy VERIFIED: SSN/Email/CreditCard/ApiKey redaction (6 tests). Commit e7913db adds pattern-based sanitization validation. All tests PASS (249/253)." +AEG-V16-017,S6,Cross,FieldShell 표준,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V16-017_FIELDSHELL_SLICE_NOTE.md; frontend/src/shared/ui/components/FieldShell.vue; frontend/src/shared/ui/components/tests/FieldShell.spec.ts","FE Lead","2026-08-08: FieldShell now owns label/error/help/ARIA relationships for KsTextField, KsTextArea, KsSelect, KsDateField, and KsNumberField. Actual evidence: frontend pnpm typecheck PASS; pnpm test PASS (19 files, 42 tests); pnpm build PASS. Build emitted unrelated tracked .js drift, excluded from this Slice. COMPLETED is blocked pending WBS Master/tracker reconciliation and AEG-V16-016 vendor-boundary acceptance evidence." AEG-X-008,S0,Cross,OpenAPI artifact 고도화,COMPLETED,2026-08-04,.gitea/workflows/openapi-gate.yml + docs/api/openapi.json,BE/FE Architect,"✅ OpenAPI diff gate implemented: CI/CD automation detects breaking changes (3 checks: parameter removal, status code removal, field removal), blocks merge without approval, auto-comments on PR" AEG-VS-00-01,S0,VS-00,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-06,"docs/CURRENT/SLICE_SPECS/VS-00-SLICE_SPEC.md + commit e7913db",PM/Architect,"✅ SLICE_SPEC produced: VS-00-SLICE_SPEC.md (state transitions, RBAC, governance gates, DQ rules, compliance). Commit e7913db. 249/253 tests PASS." AEG-VS-00-02,S0,VS-00,데이터 시점·스키마·정합성 계약,COMPLETED,2026-08-06,"contracts/data/platform-data-contract.v1.json + commit e7913db",Data Architect/DBA,"✅ DATA_CONTRACT v1.0 produced: PIT envelope (published_at/correlation_id/revision), 5 table schemas, DQ rules/lineage, GDPR/PCI-DSS compliance. JSON schema + validation. 249/253 tests PASS." diff --git a/frontend/src/shared/ui/components/FieldShell.vue b/frontend/src/shared/ui/components/FieldShell.vue index 4785ffb9..3702da7e 100644 --- a/frontend/src/shared/ui/components/FieldShell.vue +++ b/frontend/src/shared/ui/components/FieldShell.vue @@ -8,7 +8,7 @@ const messageId = computed(() => props.error || props.help ? `${resolvedId.value diff --git a/frontend/src/shared/ui/components/FieldShell.vue.js b/frontend/src/shared/ui/components/FieldShell.vue.js index e0c0fd78..d6fbede5 100644 --- a/frontend/src/shared/ui/components/FieldShell.vue.js +++ b/frontend/src/shared/ui/components/FieldShell.vue.js @@ -30,6 +30,7 @@ var __VLS_0 = { inputId: (__VLS_ctx.resolvedId), describedBy: (__VLS_ctx.messageId), invalid: (Boolean(__VLS_ctx.error)), + required: (Boolean(__VLS_ctx.required)), }; if (__VLS_ctx.error || __VLS_ctx.help) { __VLS_asFunctionalElement1(__VLS_intrinsics.small, __VLS_intrinsics.small)({ @@ -43,7 +44,7 @@ if (__VLS_ctx.error || __VLS_ctx.help) { // @ts-ignore var __VLS_1 = __VLS_0; // @ts-ignore -[error, error, error, error, error, error, resolvedId, resolvedId, label, required, messageId, messageId, help, help,]; +[error, error, error, error, error, error, resolvedId, resolvedId, label, required, required, messageId, messageId, help, help,]; const __VLS_base = (await import('vue')).defineComponent({ __typeProps: {}, }); diff --git a/frontend/src/shared/ui/components/KsDateField.vue b/frontend/src/shared/ui/components/KsDateField.vue index 876298ad..c958a40d 100644 --- a/frontend/src/shared/ui/components/KsDateField.vue +++ b/frontend/src/shared/ui/components/KsDateField.vue @@ -1,11 +1,8 @@ - - + diff --git a/frontend/src/shared/ui/components/KsDateField.vue.js b/frontend/src/shared/ui/components/KsDateField.vue.js index 930bf768..0c5552f7 100644 --- a/frontend/src/shared/ui/components/KsDateField.vue.js +++ b/frontend/src/shared/ui/components/KsDateField.vue.js @@ -1,10 +1,8 @@ -import { computed, useId } from 'vue'; import { useUiAdapter } from '../adapter/useUiAdapter'; +import FieldShell from './FieldShell.vue'; const props = defineProps(); const emit = defineEmits(); const adapter = useUiAdapter(); -const generatedId = useId(); -const resolvedId = computed(() => props.inputId ?? `ks-date-${generatedId}`); const __VLS_ctx = { ...{}, ...{}, @@ -15,72 +13,78 @@ const __VLS_ctx = { let __VLS_components; let __VLS_intrinsics; let __VLS_directives; -__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({ - ...{ class: "ks-field" }, -}); -/** @type {__VLS_StyleScopedClasses['ks-field']} */ ; -__VLS_asFunctionalElement1(__VLS_intrinsics.label, __VLS_intrinsics.label)({ - for: (__VLS_ctx.resolvedId), -}); -(__VLS_ctx.label); -if (__VLS_ctx.required) { - __VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({ - 'aria-hidden': "true", - }); -} -const __VLS_0 = (__VLS_ctx.adapter.components.DateField); +const __VLS_0 = FieldShell || FieldShell; // @ts-ignore const __VLS_1 = __VLS_asFunctionalComponent1(__VLS_0, new __VLS_0({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - min: (__VLS_ctx.min), - max: (__VLS_ctx.max), - 'aria-describedby': (__VLS_ctx.error || __VLS_ctx.help ? `${__VLS_ctx.resolvedId}-message` : undefined), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), })); const __VLS_2 = __VLS_1({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - min: (__VLS_ctx.min), - max: (__VLS_ctx.max), - 'aria-describedby': (__VLS_ctx.error || __VLS_ctx.help ? `${__VLS_ctx.resolvedId}-message` : undefined), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), }, ...__VLS_functionalComponentArgsRest(__VLS_1)); -let __VLS_5; -const __VLS_6 = { - /** @type {typeof __VLS_5.'update:modelValue'} */ - 'onUpdate:modelValue': (...[$event]) => { - return (__VLS_ctx.emit('update:modelValue', $event)); - // @ts-ignore - [resolvedId, resolvedId, resolvedId, label, required, adapter, modelValue, disabled, error, error, min, max, help, emit,]; - }, -}; -const __VLS_7 = { - /** @type {typeof __VLS_5.blur} */ - onBlur: (...[$event]) => { - return (__VLS_ctx.emit('blur', $event)); - // @ts-ignore - [emit,]; - }, -}; -var __VLS_3; -var __VLS_4; -if (__VLS_ctx.error || __VLS_ctx.help) { - __VLS_asFunctionalElement1(__VLS_intrinsics.small, __VLS_intrinsics.small)({ - id: (`${__VLS_ctx.resolvedId}-message`), - ...{ class: ({ 'ks-danger-text': __VLS_ctx.error }) }, - }); - /** @type {__VLS_StyleScopedClasses['ks-danger-text']} */ ; - (__VLS_ctx.error ?? __VLS_ctx.help); +var __VLS_5; +{ + const { default: __VLS_6 } = __VLS_3.slots; + const [field] = __VLS_vSlot(__VLS_6); + const __VLS_7 = (__VLS_ctx.adapter.components.DateField); + // @ts-ignore + const __VLS_8 = __VLS_asFunctionalComponent1(__VLS_7, new __VLS_7({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + min: (__VLS_ctx.min), + max: (__VLS_ctx.max), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + })); + const __VLS_9 = __VLS_8({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + min: (__VLS_ctx.min), + max: (__VLS_ctx.max), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + }, ...__VLS_functionalComponentArgsRest(__VLS_8)); + let __VLS_12; + const __VLS_13 = { + /** @type {typeof __VLS_12.'update:modelValue'} */ + 'onUpdate:modelValue': (...[$event]) => { + return (__VLS_ctx.emit('update:modelValue', $event)); + // @ts-ignore + [label, inputId, required, error, help, adapter, modelValue, disabled, min, max, emit,]; + }, + }; + const __VLS_14 = { + /** @type {typeof __VLS_12.blur} */ + onBlur: (...[$event]) => { + return (__VLS_ctx.emit('blur', $event)); + // @ts-ignore + [emit,]; + }, + }; + var __VLS_10; + var __VLS_11; + // @ts-ignore + []; + __VLS_3.slots['' /* empty slot name completion */]; } +var __VLS_3; // @ts-ignore -[resolvedId, error, error, error, help, help,]; +[]; const __VLS_export = (await import('vue')).defineComponent({ __typeEmits: {}, __typeProps: {}, diff --git a/frontend/src/shared/ui/components/KsNumberField.vue b/frontend/src/shared/ui/components/KsNumberField.vue index 0bb2deff..579bfef1 100644 --- a/frontend/src/shared/ui/components/KsNumberField.vue +++ b/frontend/src/shared/ui/components/KsNumberField.vue @@ -1,11 +1,8 @@ - - + diff --git a/frontend/src/shared/ui/components/KsNumberField.vue.js b/frontend/src/shared/ui/components/KsNumberField.vue.js index f0f14977..a1302552 100644 --- a/frontend/src/shared/ui/components/KsNumberField.vue.js +++ b/frontend/src/shared/ui/components/KsNumberField.vue.js @@ -1,10 +1,8 @@ -import { computed, useId } from 'vue'; import { useUiAdapter } from '../adapter/useUiAdapter'; +import FieldShell from './FieldShell.vue'; const props = defineProps(); const emit = defineEmits(); const adapter = useUiAdapter(); -const generatedId = useId(); -const resolvedId = computed(() => props.inputId ?? `ks-number-${generatedId}`); const __VLS_ctx = { ...{}, ...{}, @@ -15,76 +13,82 @@ const __VLS_ctx = { let __VLS_components; let __VLS_intrinsics; let __VLS_directives; -__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({ - ...{ class: "ks-field" }, -}); -/** @type {__VLS_StyleScopedClasses['ks-field']} */ ; -__VLS_asFunctionalElement1(__VLS_intrinsics.label, __VLS_intrinsics.label)({ - for: (__VLS_ctx.resolvedId), -}); -(__VLS_ctx.label); -if (__VLS_ctx.required) { - __VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({ - 'aria-hidden': "true", - }); -} -const __VLS_0 = (__VLS_ctx.adapter.components.NumberField); +const __VLS_0 = FieldShell || FieldShell; // @ts-ignore const __VLS_1 = __VLS_asFunctionalComponent1(__VLS_0, new __VLS_0({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - min: (__VLS_ctx.min), - max: (__VLS_ctx.max), - minFractionDigits: (__VLS_ctx.minFractionDigits), - maxFractionDigits: (__VLS_ctx.maxFractionDigits), - 'aria-describedby': (__VLS_ctx.error || __VLS_ctx.help ? `${__VLS_ctx.resolvedId}-message` : undefined), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), })); const __VLS_2 = __VLS_1({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - min: (__VLS_ctx.min), - max: (__VLS_ctx.max), - minFractionDigits: (__VLS_ctx.minFractionDigits), - maxFractionDigits: (__VLS_ctx.maxFractionDigits), - 'aria-describedby': (__VLS_ctx.error || __VLS_ctx.help ? `${__VLS_ctx.resolvedId}-message` : undefined), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), }, ...__VLS_functionalComponentArgsRest(__VLS_1)); -let __VLS_5; -const __VLS_6 = { - /** @type {typeof __VLS_5.'update:modelValue'} */ - 'onUpdate:modelValue': (...[$event]) => { - return (__VLS_ctx.emit('update:modelValue', $event)); - // @ts-ignore - [resolvedId, resolvedId, resolvedId, label, required, adapter, modelValue, disabled, error, error, min, max, minFractionDigits, maxFractionDigits, help, emit,]; - }, -}; -const __VLS_7 = { - /** @type {typeof __VLS_5.blur} */ - onBlur: (...[$event]) => { - return (__VLS_ctx.emit('blur', $event)); - // @ts-ignore - [emit,]; - }, -}; -var __VLS_3; -var __VLS_4; -if (__VLS_ctx.error || __VLS_ctx.help) { - __VLS_asFunctionalElement1(__VLS_intrinsics.small, __VLS_intrinsics.small)({ - id: (`${__VLS_ctx.resolvedId}-message`), - ...{ class: ({ 'ks-danger-text': __VLS_ctx.error }) }, - }); - /** @type {__VLS_StyleScopedClasses['ks-danger-text']} */ ; - (__VLS_ctx.error ?? __VLS_ctx.help); +var __VLS_5; +{ + const { default: __VLS_6 } = __VLS_3.slots; + const [field] = __VLS_vSlot(__VLS_6); + const __VLS_7 = (__VLS_ctx.adapter.components.NumberField); + // @ts-ignore + const __VLS_8 = __VLS_asFunctionalComponent1(__VLS_7, new __VLS_7({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + min: (__VLS_ctx.min), + max: (__VLS_ctx.max), + minFractionDigits: (__VLS_ctx.minFractionDigits), + maxFractionDigits: (__VLS_ctx.maxFractionDigits), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + })); + const __VLS_9 = __VLS_8({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + min: (__VLS_ctx.min), + max: (__VLS_ctx.max), + minFractionDigits: (__VLS_ctx.minFractionDigits), + maxFractionDigits: (__VLS_ctx.maxFractionDigits), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + }, ...__VLS_functionalComponentArgsRest(__VLS_8)); + let __VLS_12; + const __VLS_13 = { + /** @type {typeof __VLS_12.'update:modelValue'} */ + 'onUpdate:modelValue': (...[$event]) => { + return (__VLS_ctx.emit('update:modelValue', $event)); + // @ts-ignore + [label, inputId, required, error, help, adapter, modelValue, disabled, min, max, minFractionDigits, maxFractionDigits, emit,]; + }, + }; + const __VLS_14 = { + /** @type {typeof __VLS_12.blur} */ + onBlur: (...[$event]) => { + return (__VLS_ctx.emit('blur', $event)); + // @ts-ignore + [emit,]; + }, + }; + var __VLS_10; + var __VLS_11; + // @ts-ignore + []; + __VLS_3.slots['' /* empty slot name completion */]; } +var __VLS_3; // @ts-ignore -[resolvedId, error, error, error, help, help,]; +[]; const __VLS_export = (await import('vue')).defineComponent({ __typeEmits: {}, __typeProps: {}, diff --git a/frontend/src/shared/ui/components/KsSelect.vue b/frontend/src/shared/ui/components/KsSelect.vue index ff936f2a..f6c2141d 100644 --- a/frontend/src/shared/ui/components/KsSelect.vue +++ b/frontend/src/shared/ui/components/KsSelect.vue @@ -1,18 +1,13 @@ - diff --git a/frontend/src/shared/ui/components/KsSelect.vue.js b/frontend/src/shared/ui/components/KsSelect.vue.js index d728a7df..56400cd9 100644 --- a/frontend/src/shared/ui/components/KsSelect.vue.js +++ b/frontend/src/shared/ui/components/KsSelect.vue.js @@ -1,10 +1,8 @@ -import { computed, useId } from 'vue'; import { useUiAdapter } from '../adapter/useUiAdapter'; +import FieldShell from './FieldShell.vue'; const props = defineProps(); const emit = defineEmits(); const adapter = useUiAdapter(); -const generatedId = useId(); -const resolvedId = computed(() => props.inputId ?? `ks-select-${generatedId}`); const __VLS_ctx = { ...{}, ...{}, @@ -15,69 +13,78 @@ const __VLS_ctx = { let __VLS_components; let __VLS_intrinsics; let __VLS_directives; -__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({ - ...{ class: "ks-field" }, -}); -/** @type {__VLS_StyleScopedClasses['ks-field']} */ ; -__VLS_asFunctionalElement1(__VLS_intrinsics.label, __VLS_intrinsics.label)({ - for: (__VLS_ctx.resolvedId), -}); -(__VLS_ctx.label); -if (__VLS_ctx.required) { - __VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({ - 'aria-hidden': "true", - }); -} -const __VLS_0 = (__VLS_ctx.adapter.components.Select); +const __VLS_0 = FieldShell || FieldShell; // @ts-ignore const __VLS_1 = __VLS_asFunctionalComponent1(__VLS_0, new __VLS_0({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - options: (__VLS_ctx.options), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - placeholder: (__VLS_ctx.placeholder), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), })); const __VLS_2 = __VLS_1({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - options: (__VLS_ctx.options), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - placeholder: (__VLS_ctx.placeholder), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), }, ...__VLS_functionalComponentArgsRest(__VLS_1)); -let __VLS_5; -const __VLS_6 = { - /** @type {typeof __VLS_5.'update:modelValue'} */ - 'onUpdate:modelValue': (...[$event]) => { - return (__VLS_ctx.emit('update:modelValue', $event)); - // @ts-ignore - [resolvedId, resolvedId, label, required, adapter, modelValue, options, disabled, error, placeholder, emit,]; - }, -}; -const __VLS_7 = { - /** @type {typeof __VLS_5.blur} */ - onBlur: (...[$event]) => { - return (__VLS_ctx.emit('blur', $event)); - // @ts-ignore - [emit,]; - }, -}; -var __VLS_3; -var __VLS_4; -if (__VLS_ctx.error || __VLS_ctx.help) { - __VLS_asFunctionalElement1(__VLS_intrinsics.small, __VLS_intrinsics.small)({ - ...{ class: ({ 'ks-danger-text': __VLS_ctx.error }) }, - }); - /** @type {__VLS_StyleScopedClasses['ks-danger-text']} */ ; - (__VLS_ctx.error ?? __VLS_ctx.help); +var __VLS_5; +{ + const { default: __VLS_6 } = __VLS_3.slots; + const [field] = __VLS_vSlot(__VLS_6); + const __VLS_7 = (__VLS_ctx.adapter.components.Select); + // @ts-ignore + const __VLS_8 = __VLS_asFunctionalComponent1(__VLS_7, new __VLS_7({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + options: (__VLS_ctx.options), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + placeholder: (__VLS_ctx.placeholder), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + })); + const __VLS_9 = __VLS_8({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + options: (__VLS_ctx.options), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + placeholder: (__VLS_ctx.placeholder), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + }, ...__VLS_functionalComponentArgsRest(__VLS_8)); + let __VLS_12; + const __VLS_13 = { + /** @type {typeof __VLS_12.'update:modelValue'} */ + 'onUpdate:modelValue': (...[$event]) => { + return (__VLS_ctx.emit('update:modelValue', $event)); + // @ts-ignore + [label, inputId, required, error, help, adapter, modelValue, options, disabled, placeholder, emit,]; + }, + }; + const __VLS_14 = { + /** @type {typeof __VLS_12.blur} */ + onBlur: (...[$event]) => { + return (__VLS_ctx.emit('blur', $event)); + // @ts-ignore + [emit,]; + }, + }; + var __VLS_10; + var __VLS_11; + // @ts-ignore + []; + __VLS_3.slots['' /* empty slot name completion */]; } +var __VLS_3; // @ts-ignore -[error, error, error, help, help,]; +[]; const __VLS_export = (await import('vue')).defineComponent({ __typeEmits: {}, __typeProps: {}, diff --git a/frontend/src/shared/ui/components/KsTextArea.vue b/frontend/src/shared/ui/components/KsTextArea.vue index 4c3c5179..2b51688c 100644 --- a/frontend/src/shared/ui/components/KsTextArea.vue +++ b/frontend/src/shared/ui/components/KsTextArea.vue @@ -1,17 +1,12 @@ - diff --git a/frontend/src/shared/ui/components/KsTextArea.vue.js b/frontend/src/shared/ui/components/KsTextArea.vue.js index f2e07b6f..7af11ea0 100644 --- a/frontend/src/shared/ui/components/KsTextArea.vue.js +++ b/frontend/src/shared/ui/components/KsTextArea.vue.js @@ -1,10 +1,8 @@ -import { computed, useId } from 'vue'; import { useUiAdapter } from '../adapter/useUiAdapter'; +import FieldShell from './FieldShell.vue'; const props = defineProps(); const emit = defineEmits(); const adapter = useUiAdapter(); -const generatedId = useId(); -const resolvedId = computed(() => props.inputId ?? `ks-area-${generatedId}`); const __VLS_ctx = { ...{}, ...{}, @@ -15,69 +13,78 @@ const __VLS_ctx = { let __VLS_components; let __VLS_intrinsics; let __VLS_directives; -__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({ - ...{ class: "ks-field" }, -}); -/** @type {__VLS_StyleScopedClasses['ks-field']} */ ; -__VLS_asFunctionalElement1(__VLS_intrinsics.label, __VLS_intrinsics.label)({ - for: (__VLS_ctx.resolvedId), -}); -(__VLS_ctx.label); -if (__VLS_ctx.required) { - __VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({ - 'aria-hidden': "true", - }); -} -const __VLS_0 = (__VLS_ctx.adapter.components.TextArea); +const __VLS_0 = FieldShell || FieldShell; // @ts-ignore const __VLS_1 = __VLS_asFunctionalComponent1(__VLS_0, new __VLS_0({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - rows: (__VLS_ctx.rows), - placeholder: (__VLS_ctx.placeholder), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), })); const __VLS_2 = __VLS_1({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - rows: (__VLS_ctx.rows), - placeholder: (__VLS_ctx.placeholder), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), }, ...__VLS_functionalComponentArgsRest(__VLS_1)); -let __VLS_5; -const __VLS_6 = { - /** @type {typeof __VLS_5.'update:modelValue'} */ - 'onUpdate:modelValue': (...[$event]) => { - return (__VLS_ctx.emit('update:modelValue', $event)); - // @ts-ignore - [resolvedId, resolvedId, label, required, adapter, modelValue, disabled, error, rows, placeholder, emit,]; - }, -}; -const __VLS_7 = { - /** @type {typeof __VLS_5.blur} */ - onBlur: (...[$event]) => { - return (__VLS_ctx.emit('blur', $event)); - // @ts-ignore - [emit,]; - }, -}; -var __VLS_3; -var __VLS_4; -if (__VLS_ctx.error || __VLS_ctx.help) { - __VLS_asFunctionalElement1(__VLS_intrinsics.small, __VLS_intrinsics.small)({ - ...{ class: ({ 'ks-danger-text': __VLS_ctx.error }) }, - }); - /** @type {__VLS_StyleScopedClasses['ks-danger-text']} */ ; - (__VLS_ctx.error ?? __VLS_ctx.help); +var __VLS_5; +{ + const { default: __VLS_6 } = __VLS_3.slots; + const [field] = __VLS_vSlot(__VLS_6); + const __VLS_7 = (__VLS_ctx.adapter.components.TextArea); + // @ts-ignore + const __VLS_8 = __VLS_asFunctionalComponent1(__VLS_7, new __VLS_7({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + rows: (__VLS_ctx.rows), + placeholder: (__VLS_ctx.placeholder), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + })); + const __VLS_9 = __VLS_8({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + rows: (__VLS_ctx.rows), + placeholder: (__VLS_ctx.placeholder), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + }, ...__VLS_functionalComponentArgsRest(__VLS_8)); + let __VLS_12; + const __VLS_13 = { + /** @type {typeof __VLS_12.'update:modelValue'} */ + 'onUpdate:modelValue': (...[$event]) => { + return (__VLS_ctx.emit('update:modelValue', $event)); + // @ts-ignore + [label, inputId, required, error, help, adapter, modelValue, disabled, rows, placeholder, emit,]; + }, + }; + const __VLS_14 = { + /** @type {typeof __VLS_12.blur} */ + onBlur: (...[$event]) => { + return (__VLS_ctx.emit('blur', $event)); + // @ts-ignore + [emit,]; + }, + }; + var __VLS_10; + var __VLS_11; + // @ts-ignore + []; + __VLS_3.slots['' /* empty slot name completion */]; } +var __VLS_3; // @ts-ignore -[error, error, error, help, help,]; +[]; const __VLS_export = (await import('vue')).defineComponent({ __typeEmits: {}, __typeProps: {}, diff --git a/frontend/src/shared/ui/components/KsTextField.vue b/frontend/src/shared/ui/components/KsTextField.vue index a0ff2e2b..df896ae4 100644 --- a/frontend/src/shared/ui/components/KsTextField.vue +++ b/frontend/src/shared/ui/components/KsTextField.vue @@ -1,34 +1,25 @@ - - diff --git a/frontend/src/shared/ui/components/KsTextField.vue.js b/frontend/src/shared/ui/components/KsTextField.vue.js index 12ebe081..a7c440c7 100644 --- a/frontend/src/shared/ui/components/KsTextField.vue.js +++ b/frontend/src/shared/ui/components/KsTextField.vue.js @@ -1,10 +1,8 @@ -import { computed, useId } from 'vue'; import { useUiAdapter } from '../adapter/useUiAdapter'; +import FieldShell from './FieldShell.vue'; const props = defineProps(); const emit = defineEmits(); const adapter = useUiAdapter(); -const generatedId = useId(); -const resolvedId = computed(() => props.inputId ?? `ks-field-${generatedId}`); const __VLS_ctx = { ...{}, ...{}, @@ -15,70 +13,76 @@ const __VLS_ctx = { let __VLS_components; let __VLS_intrinsics; let __VLS_directives; -__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({ - ...{ class: "ks-field" }, -}); -/** @type {__VLS_StyleScopedClasses['ks-field']} */ ; -__VLS_asFunctionalElement1(__VLS_intrinsics.label, __VLS_intrinsics.label)({ - for: (__VLS_ctx.resolvedId), -}); -(__VLS_ctx.label); -if (__VLS_ctx.required) { - __VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({ - 'aria-hidden': "true", - }); -} -const __VLS_0 = (__VLS_ctx.adapter.components.TextField); +const __VLS_0 = FieldShell || FieldShell; // @ts-ignore const __VLS_1 = __VLS_asFunctionalComponent1(__VLS_0, new __VLS_0({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - placeholder: (__VLS_ctx.placeholder), - 'aria-describedby': (__VLS_ctx.error || __VLS_ctx.help ? `${__VLS_ctx.resolvedId}-message` : undefined), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), })); const __VLS_2 = __VLS_1({ - ...{ 'onUpdate:modelValue': {} }, - ...{ 'onBlur': {} }, - inputId: (__VLS_ctx.resolvedId), - modelValue: (__VLS_ctx.modelValue), - disabled: (__VLS_ctx.disabled), - invalid: (Boolean(__VLS_ctx.error)), - placeholder: (__VLS_ctx.placeholder), - 'aria-describedby': (__VLS_ctx.error || __VLS_ctx.help ? `${__VLS_ctx.resolvedId}-message` : undefined), + label: (__VLS_ctx.label), + inputId: (__VLS_ctx.inputId), + required: (__VLS_ctx.required), + error: (__VLS_ctx.error), + help: (__VLS_ctx.help), }, ...__VLS_functionalComponentArgsRest(__VLS_1)); -let __VLS_5; -const __VLS_6 = { - /** @type {typeof __VLS_5.'update:modelValue'} */ - 'onUpdate:modelValue': (...[$event]) => { - return (__VLS_ctx.emit('update:modelValue', $event)); - // @ts-ignore - [resolvedId, resolvedId, resolvedId, label, required, adapter, modelValue, disabled, error, error, placeholder, help, emit,]; - }, -}; -const __VLS_7 = { - /** @type {typeof __VLS_5.blur} */ - onBlur: (...[$event]) => { - return (__VLS_ctx.emit('blur', $event)); - // @ts-ignore - [emit,]; - }, -}; -var __VLS_3; -var __VLS_4; -if (__VLS_ctx.error || __VLS_ctx.help) { - __VLS_asFunctionalElement1(__VLS_intrinsics.small, __VLS_intrinsics.small)({ - id: (`${__VLS_ctx.resolvedId}-message`), - ...{ class: ({ 'ks-danger-text': __VLS_ctx.error }) }, - }); - /** @type {__VLS_StyleScopedClasses['ks-danger-text']} */ ; - (__VLS_ctx.error ?? __VLS_ctx.help); +var __VLS_5; +{ + const { default: __VLS_6 } = __VLS_3.slots; + const [field] = __VLS_vSlot(__VLS_6); + const __VLS_7 = (__VLS_ctx.adapter.components.TextField); + // @ts-ignore + const __VLS_8 = __VLS_asFunctionalComponent1(__VLS_7, new __VLS_7({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + placeholder: (__VLS_ctx.placeholder), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + })); + const __VLS_9 = __VLS_8({ + ...{ 'onUpdate:modelValue': {} }, + ...{ 'onBlur': {} }, + inputId: (field.inputId), + modelValue: (__VLS_ctx.modelValue), + disabled: (__VLS_ctx.disabled), + invalid: (field.invalid), + placeholder: (__VLS_ctx.placeholder), + 'aria-describedby': (field.describedBy), + 'aria-required': (field.required || undefined), + }, ...__VLS_functionalComponentArgsRest(__VLS_8)); + let __VLS_12; + const __VLS_13 = { + /** @type {typeof __VLS_12.'update:modelValue'} */ + 'onUpdate:modelValue': (...[$event]) => { + return (__VLS_ctx.emit('update:modelValue', $event)); + // @ts-ignore + [label, inputId, required, error, help, adapter, modelValue, disabled, placeholder, emit,]; + }, + }; + const __VLS_14 = { + /** @type {typeof __VLS_12.blur} */ + onBlur: (...[$event]) => { + return (__VLS_ctx.emit('blur', $event)); + // @ts-ignore + [emit,]; + }, + }; + var __VLS_10; + var __VLS_11; + // @ts-ignore + []; + __VLS_3.slots['' /* empty slot name completion */]; } +var __VLS_3; // @ts-ignore -[resolvedId, error, error, error, help, help,]; +[]; const __VLS_export = (await import('vue')).defineComponent({ __typeEmits: {}, __typeProps: {}, diff --git a/frontend/src/shared/ui/components/tests/FieldShell.spec.js b/frontend/src/shared/ui/components/tests/FieldShell.spec.js new file mode 100644 index 00000000..eb8e8143 --- /dev/null +++ b/frontend/src/shared/ui/components/tests/FieldShell.spec.js @@ -0,0 +1,36 @@ +import { h } from 'vue'; +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import FieldShell from '../FieldShell.vue'; +describe('FieldShell', () => { + it('connects label, required state, invalid state, and error to the control', () => { + const wrapper = mount(FieldShell, { + props: { label: 'Customer name', inputId: 'customer-name', required: true, error: 'Required field' }, + slots: { + default: ({ inputId, describedBy, invalid, required }) => h('input', { + id: inputId, + 'aria-describedby': describedBy, + 'aria-invalid': invalid, + 'aria-required': required + }) + } + }); + expect(wrapper.get('label').attributes('for')).toBe('customer-name'); + expect(wrapper.get('input').attributes()).toMatchObject({ + id: 'customer-name', + 'aria-describedby': 'customer-name-message', + 'aria-invalid': 'true', + 'aria-required': 'true' + }); + expect(wrapper.get('[role="alert"]').attributes('id')).toBe('customer-name-message'); + expect(wrapper.text()).toContain('Required field'); + }); + it('uses help text without announcing it as an error', () => { + const wrapper = mount(FieldShell, { + props: { label: 'Settlement date', inputId: 'settlement-date', help: 'Use the market calendar date.' }, + slots: { default: ({ inputId, describedBy }) => h('input', { id: inputId, 'aria-describedby': describedBy }) } + }); + expect(wrapper.get('small').attributes()).toMatchObject({ id: 'settlement-date-message' }); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); + }); +}); diff --git a/frontend/src/shared/ui/components/tests/FieldShell.spec.ts b/frontend/src/shared/ui/components/tests/FieldShell.spec.ts new file mode 100644 index 00000000..4f92bf94 --- /dev/null +++ b/frontend/src/shared/ui/components/tests/FieldShell.spec.ts @@ -0,0 +1,40 @@ +import { h } from 'vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import FieldShell from '../FieldShell.vue' + +describe('FieldShell', () => { + it('connects label, required state, invalid state, and error to the control', () => { + const wrapper = mount(FieldShell, { + props: { label: 'Customer name', inputId: 'customer-name', required: true, error: 'Required field' }, + slots: { + default: ({ inputId, describedBy, invalid, required }) => h('input', { + id: inputId, + 'aria-describedby': describedBy, + 'aria-invalid': invalid, + 'aria-required': required + }) + } + }) + + expect(wrapper.get('label').attributes('for')).toBe('customer-name') + expect(wrapper.get('input').attributes()).toMatchObject({ + id: 'customer-name', + 'aria-describedby': 'customer-name-message', + 'aria-invalid': 'true', + 'aria-required': 'true' + }) + expect(wrapper.get('[role="alert"]').attributes('id')).toBe('customer-name-message') + expect(wrapper.text()).toContain('Required field') + }) + + it('uses help text without announcing it as an error', () => { + const wrapper = mount(FieldShell, { + props: { label: 'Settlement date', inputId: 'settlement-date', help: 'Use the market calendar date.' }, + slots: { default: ({ inputId, describedBy }) => h('input', { id: inputId, 'aria-describedby': describedBy }) } + }) + + expect(wrapper.get('small').attributes()).toMatchObject({ id: 'settlement-date-message' }) + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + }) +})