diff --git a/package-lock.json b/package-lock.json index 6bfae02d0..ae075796c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15815,6 +15815,11 @@ "node": ">=8" } }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -17483,6 +17488,17 @@ "typescript": "*" } }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -18039,7 +18055,8 @@ "dependencies": { "@vueuse/core": "^10.9.0", "lodash-es": "^4.17.21", - "nanoid": "^5.0.7" + "nanoid": "^5.0.7", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@chromatic-com/storybook": "^1.3.2", diff --git a/packages/vlossom/package.json b/packages/vlossom/package.json index b8fc9a095..e4744123b 100644 --- a/packages/vlossom/package.json +++ b/packages/vlossom/package.json @@ -89,6 +89,7 @@ "dependencies": { "@vueuse/core": "^10.9.0", "lodash-es": "^4.17.21", + "vuedraggable": "^4.1.0", "nanoid": "^5.0.7" } } diff --git a/packages/vlossom/src/components/index.ts b/packages/vlossom/src/components/index.ts index 7b82004b3..287a0496f 100644 --- a/packages/vlossom/src/components/index.ts +++ b/packages/vlossom/src/components/index.ts @@ -94,6 +94,9 @@ export { default as VsStepper } from './vs-stepper/VsStepper.vue'; export { type VsSwitchStyleSet } from './vs-switch/types'; export { default as VsSwitch } from './vs-switch/VsSwitch.vue'; +export { type VsTableStyleSet } from './vs-table/types'; +export { default as VsTable } from './vs-table/VsTable.vue'; + export { type VsTabsStyleSet } from './vs-tabs/types'; export { default as VsTabs } from './vs-tabs/VsTabs.vue'; @@ -150,6 +153,7 @@ declare module 'vue' { VsSelect: typeof import('./')['VsSelect']; VsStepper: typeof import('./')['VsStepper']; VsSwitch: typeof import('./')['VsSwitch']; + VsTable: typeof import('./')['VsTable']; VsTabs: typeof import('./')['VsTabs']; VsTextarea: typeof import('./')['VsTextarea']; VsTextWrap: typeof import('./')['VsTextWrap']; diff --git a/packages/vlossom/src/components/vs-select/VsSelect.vue b/packages/vlossom/src/components/vs-select/VsSelect.vue index dca1ee0f3..977684b56 100644 --- a/packages/vlossom/src/components/vs-select/VsSelect.vue +++ b/packages/vlossom/src/components/vs-select/VsSelect.vue @@ -62,6 +62,7 @@ :id="id" role="combobox" :aria-expanded="isOpen || isVisible" + :aria-label="ariaLabel" aria-controls="vs-select-options" :aria-autocomplete="autocomplete ? 'list' : undefined" :aria-activedescendant="focusedOptionId" @@ -220,6 +221,7 @@ export default defineComponent({ ...getResponsiveProps(), colorScheme: { type: String as PropType }, styleSet: { type: [String, Object] as PropType }, + ariaLabel: { type: String, default: '' }, autocomplete: { type: Boolean, default: false }, closableChips: { type: Boolean, diff --git a/packages/vlossom/src/components/vs-select/__tests__/vs-select.test.ts b/packages/vlossom/src/components/vs-select/__tests__/vs-select.test.ts index 90f8f120c..c613d327d 100644 --- a/packages/vlossom/src/components/vs-select/__tests__/vs-select.test.ts +++ b/packages/vlossom/src/components/vs-select/__tests__/vs-select.test.ts @@ -767,6 +767,20 @@ describe('vs-select', () => { }); }); + describe('aria-label', () => { + it('aria-label을 설정할 수 있다', () => { + // given + const wrapper: ReturnType = mount(VsSelect, { + props: { + ariaLabel: 'aria-label', + }, + }); + + // then + expect(wrapper.find('input').attributes('aria-label')).toBe('aria-label'); + }); + }); + describe('focus / blur', () => { it('focus 이벤트를 발생시킬 수 있다', async () => { // given diff --git a/packages/vlossom/src/components/vs-table/VsTable.scss b/packages/vlossom/src/components/vs-table/VsTable.scss new file mode 100644 index 000000000..bc90b256f --- /dev/null +++ b/packages/vlossom/src/components/vs-table/VsTable.scss @@ -0,0 +1,314 @@ +@use '@/styles/mixin' as *; + +.vs-table .table-wrap { + width: 100%; + overflow-x: auto; + + table { + min-width: 100%; + + caption { + caption-side: top; + text-align: center; + padding-bottom: 1rem; + font-weight: bold; + font-size: 1.2rem; + color: var(--vs-font-color); + } + + tr { + display: grid; + justify-content: flex-start; + overflow: hidden; + + th, + td { + display: flex; + align-items: center; + padding: 0.4rem 1.2rem; + text-align: left; + + .table-data { + display: flex; + align-items: center; + width: 100%; + height: 100%; + overflow-x: auto; + } + } + } + + thead tr { + background-color: var(--vs-comp-backgroundColor); + + th { + color: var(--vs-comp-color); + height: 3.6rem; + font-size: 1.1rem; + font-weight: 700; + + &.draggable-th, + &.expandable-th { + visibility: hidden; + } + + &.selectable-th { + color: transparent; + } + + &.table-th:first-of-type { + padding-left: 2rem; + } + } + } + + tbody tr { + position: relative; + transition: background-color 0.2s; + border-bottom: 1px solid var(--vs-grey-200); + color: var(--vs-font-color); + + &.selected { + background-color: var(--vs-blue-100); + color: var(--vs-blue-800); + } + + td { + min-height: 4.8rem; + font-size: 1.1rem; + + &.draggable-td { + padding-right: 0; + } + + &.table-td:first-of-type { + padding-left: 2rem; + } + + &.expandable-td { + padding-left: 0; + + > button { + display: flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + border-radius: 0.2rem; + background-color: var(--vs-comp-backgroundColor); + + i { + color: var(--vs-comp-color); + pointer-events: none; + transform: rotate(180deg); + transition: transform 0.4s; + } + + &.expanded { + i { + transform: rotate(0deg); + } + } + + &:hover { + box-shadow: 0 0 4px var(--vs-comp-backgroundColor); + } + + &:disabled { + @include disabled; + } + } + } + + &.expanded-row-content { + display: grid; + grid-column: 1/-1; + padding: 0 1rem; + border-top: 1px dashed var(--vs-line-color); + border-left: 1px dashed var(--vs-line-color); + border-right: 1px dashed var(--vs-line-color); + font-size: 1.1rem; + box-shadow: var(--vs-table-expand-box-shadow-up), var(--vs-table-expand-box-shadow-down); + } + + .skeleton { + width: 95%; + height: 60%; + border-radius: 0.4rem; + background-color: var(--vs-grey-200); + animation: skeleton-loading 0.6s infinite alternate; + } + } + } + + tbody tr:not(.skeleton) td.draggable-td { + cursor: grab; + &:active { + cursor: grabbing; + } + } + + tbody tr:not(.skeleton):hover::after { + content: ''; + box-sizing: border-box; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + background-color: var(--vs-area-backgroundColor); + pointer-events: none; + } + + .table-empty { + border-bottom: 1px solid var(--vs-grey-200); + padding: 4.8rem 0; + font-weight: 400; + text-align: center; + opacity: 0.4; + + p { + font-weight: 500; + font-size: 2rem; + } + } + } +} + +.table-pagination { + position: relative; + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0.2rem 0; + + .pagination-options { + width: 10rem; + height: 100%; + } +} + +.vs-table.dense .table-wrap table { + tr { + th, + td { + padding: 0.1rem 1.2rem; + } + } + + thead tr { + th { + height: 3.2rem; + } + } + + tbody tr { + td { + min-height: 3.2rem; + } + } +} + +@media screen and (max-width: 576px) { + .vs-table .table-wrap { + margin-bottom: 2rem; + + table thead tr { + grid-template-columns: none !important; + th:not(:first-child) { + display: none; + } + th:first-child { + color: transparent; + height: 3.2rem; + } + margin-bottom: 0.8rem; + border: 1px solid var(--vs-line-color); + } + + table { + display: flex; + flex-direction: column; + } + + table tbody tr { + display: flex !important; + flex-direction: column; + margin: 0.8rem 0 0 0; + border: 1px solid var(--vs-line-color); + + &:first-of-type { + margin-top: 0; + } + + td { + display: flex; + justify-content: space-between; + padding: 0.8rem 1.2rem !important; + border-top: 1px dashed var(--vs-line-color); + overflow-x: auto; + + &::before { + content: attr(data-label); + color: var(--vs-comp-color); + display: block; + line-height: 3rem; + margin-right: 1.4rem; + font-size: 1.1rem; + font-weight: 700; + } + + &:first-child { + border-top: none; + } + + &.expandable-td, + &.expanded-row-content { + min-height: auto; + &::before { + display: none; + } + } + &.expanded-row-content { + border-top: none; + } + + &.expandable-td { + button { + flex: 1; + } + } + + &.expanded-row-content { + display: flex !important; + padding-top: 0; + padding-bottom: 1.2rem; + + .expand-contents { + flex: 1; + } + } + + .skeleton { + height: 2.8rem; + } + } + } + + .table-empty { + border-top: 1px solid var(--vs-grey-200); + } + } +} + +@keyframes skeleton-loading { + 0% { + opacity: 0.2; + } + + 100% { + opacity: 0.4; + } +} diff --git a/packages/vlossom/src/components/vs-table/VsTable.vue b/packages/vlossom/src/components/vs-table/VsTable.vue new file mode 100644 index 000000000..f6ba01592 --- /dev/null +++ b/packages/vlossom/src/components/vs-table/VsTable.vue @@ -0,0 +1,332 @@ + + + + +