Cleanup Dropdown UI
This commit is contained in:
1 parent
cd595cb062
commit
167dd7bb99
6 files changed
+97
-55
No files matched your search
+17
-2
@@ -9,13 +9,28 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Dropdown Component</h2>
|
<h2>Dropdown Component</h2>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 40px; align-items: flex-start;">
|
||||||
|
<div>
|
||||||
|
<h3>Object Options</h3>
|
||||||
<dropdown
|
<dropdown
|
||||||
[options]="dropdownOptions"
|
[options]="dropdownOptions"
|
||||||
[(selectedValue)]="selectedDropdownValue"
|
[(value)]="selectedDropdownValue"
|
||||||
(change)="handleDropdownChange($event)"
|
(selectionChange)="handleDropdownChange($event)"
|
||||||
icon="language">
|
icon="language">
|
||||||
</dropdown>
|
</dropdown>
|
||||||
<p>Selected Value: {{ selectedDropdownValue() }}</p>
|
<p>Selected Value: {{ selectedDropdownValue() }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3>String Options (Simple)</h3>
|
||||||
|
<dropdown
|
||||||
|
[options]="['Small', 'Medium', 'Large']"
|
||||||
|
[(value)]="selectedSize"
|
||||||
|
placeholder="Choose Size">
|
||||||
|
</dropdown>
|
||||||
|
<p>Selected Size: {{ selectedSize() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -14,10 +14,12 @@ export class App {
|
|||||||
|
|
||||||
dropdownOptions: DropdownOption[] = [
|
dropdownOptions: DropdownOption[] = [
|
||||||
{ label: 'English', value: "EN" },
|
{ label: 'English', value: "EN" },
|
||||||
{ label: '한국어', value: "KR" }
|
{ label: '한국어', value: "KR" },
|
||||||
|
{ label: '日本語', value: "JP" },
|
||||||
];
|
];
|
||||||
|
|
||||||
selectedDropdownValue = signal<any>(null);
|
selectedDropdownValue = signal<any>(null);
|
||||||
|
selectedSize = signal<string | null>(null);
|
||||||
|
|
||||||
handleToggle(state: boolean) {
|
handleToggle(state: boolean) {
|
||||||
console.log('Toggle state:', state);
|
console.log('Toggle state:', state);
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
@if (icon()) {
|
@if (icon()) {
|
||||||
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span>
|
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span>
|
||||||
}
|
}
|
||||||
<span class="placeholder">{{ selectedLabel }}</span>
|
<span class="placeholder">{{ selectedLabel() }}</span>
|
||||||
<span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span>
|
<span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isOpen()) {
|
@if (isOpen()) {
|
||||||
<ul class="dropdown-list" role="listbox">
|
<ul class="dropdown-list" role="listbox">
|
||||||
@for (option of options(); track option.value; let i = $index) {
|
@for (option of normalizedOptions(); track option.value; let i = $index) {
|
||||||
<li
|
<li
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
role="option"
|
role="option"
|
||||||
[class.selected]="option.value === selectedValue()"
|
[class.selected]="option.value === value()"
|
||||||
[class.focused]="i === focusedIndex()"
|
[class.focused]="i === focusedIndex()"
|
||||||
[attr.aria-selected]="option.value === selectedValue()"
|
[attr.aria-selected]="option.value === value()"
|
||||||
(click)="selectOption(option); $event.stopPropagation()">
|
(click)="selectOption(option); $event.stopPropagation()">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ $dropdown_hover: #F5F5F5;
|
|||||||
$dropdown_selected: #FFF4F0;
|
$dropdown_selected: #FFF4F0;
|
||||||
$dropdown_accent: oklch(0.7086 0.1896 39.86);
|
$dropdown_accent: oklch(0.7086 0.1896 39.86);
|
||||||
|
|
||||||
$dropdown_dimension: (324px, 40px);
|
// 사용자님의 요구사항인 고정 너비 324px 복구
|
||||||
$dropdown_padding: (10px, 5px);
|
$dropdown_width: 324px;
|
||||||
|
$dropdown_height: 40px;
|
||||||
$dropdown_border_radius: 8px;
|
$dropdown_border_radius: 8px;
|
||||||
|
|
||||||
$dropdown_animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
$dropdown_animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -19,13 +20,14 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
width: list.nth($dropdown_dimension, 1);
|
width: $dropdown_width;
|
||||||
padding: 0; margin: 0;
|
padding: 0; margin: 0;
|
||||||
|
|
||||||
background-color: $dropdown_bg;
|
background-color: $dropdown_bg;
|
||||||
border-radius: $dropdown_border_radius;
|
border-radius: $dropdown_border_radius;
|
||||||
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
box-shadow: 0 0 0 1px $dropdown_border_color inset, 0 0 0 2px white, 0 0 0 4px $dropdown_accent;
|
box-shadow: 0 0 0 1px $dropdown_border_color inset, 0 0 0 2px white, 0 0 0 4px $dropdown_accent;
|
||||||
@@ -35,31 +37,30 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
.dropdown-box {
|
.dropdown-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
padding: 0 12px;
|
||||||
padding: list.nth($dropdown_padding, 2) list.nth($dropdown_padding, 1);
|
height: $dropdown_height;
|
||||||
height: list.nth($dropdown_dimension, 2) - list.nth($dropdown_padding, 2) * 2;
|
box-sizing: border-box;
|
||||||
|
|
||||||
border-radius: $dropdown_border_radius;
|
|
||||||
transition: library.append-transition((border-color, box-shadow), $dropdown_animation_duration $dropdown_animation_timing);
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
.prefix-icon {
|
.prefix-icon {
|
||||||
width: 24px;
|
width: 24px; height: 24px;
|
||||||
height: 24px;
|
flex-shrink: 0;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
white-space: nowrap;
|
flex-grow: 1;
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin-right: auto;
|
|
||||||
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
line-height: 1.2; // descender(g, y 등)가 잘리지 않도록 행간 확보
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
width: 24px;
|
width: 24px; height: 24px;
|
||||||
height: 24px;
|
flex-shrink: 0;
|
||||||
transition: transform $dropdown_animation_duration $dropdown_animation_timing;
|
transition: transform $dropdown_animation_duration $dropdown_animation_timing;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
@@ -74,38 +75,31 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
padding: 0; // 아이템이 꽉 차도록 패딩 제거
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
background-color: $dropdown_bg;
|
background-color: $dropdown_bg;
|
||||||
border-radius: $dropdown_border_radius;
|
border-radius: $dropdown_border_radius;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
max-height: 200px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
animation: slideIn $dropdown_animation_duration $dropdown_animation_timing;
|
animation: slideIn $dropdown_animation_duration $dropdown_animation_timing;
|
||||||
|
|
||||||
|
// 자식 요소의 배경색이 테두리를 침범하지 못하도록 하는 사용자님의 핵심 설계 복구
|
||||||
&::after {
|
&::after {
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
content: "";
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
border-radius: $dropdown_border_radius;
|
||||||
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
box-shadow: 0 0 0 1px $dropdown_border_color inset;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1001;
|
z-index: 1001; // 테두리를 최상단으로!
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
top: 0; left: 0; bottom: 0; right: 0;
|
|
||||||
border-radius: $dropdown_border_radius;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from {
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
opacity: 0;
|
to { opacity: 1; transform: translateY(0); }
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +108,8 @@ $dropdown_animation_duration: 0.2s;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.2; // descender 보호를 위해 1에서 1.2로 수정
|
||||||
transition: background-color $dropdown_animation_duration $dropdown_animation_timing;
|
transition: background-color $dropdown_animation_duration $dropdown_animation_timing;
|
||||||
|
|
||||||
&:hover, &.focused {
|
&:hover, &.focused {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, input, model, output, signal, HostListener, ElementRef
|
import { Component, input, model, output, signal, HostListener, ElementRef, computed
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export interface DropdownOption {
|
|||||||
'[class.open]': 'isOpen()',
|
'[class.open]': 'isOpen()',
|
||||||
'tabindex': '0',
|
'tabindex': '0',
|
||||||
'(click)': 'toggle()',
|
'(click)': 'toggle()',
|
||||||
'(keydown.enter)': 'toggle()',
|
'(keydown.enter)': 'onEnter($event)',
|
||||||
'(keydown.space)': 'onSpaceDown($event)',
|
'(keydown.space)': 'onSpaceDown($event)',
|
||||||
'(keydown.escape)': 'close()',
|
'(keydown.escape)': 'close()',
|
||||||
'(keydown.arrowdown)': 'onArrowDown($event)',
|
'(keydown.arrowdown)': 'onArrowDown($event)',
|
||||||
@@ -28,21 +28,30 @@ export interface DropdownOption {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class Dropdown {
|
export class Dropdown {
|
||||||
options = input<DropdownOption[]>([]);
|
options = input<Array<DropdownOption | string>>([]);
|
||||||
placeholder = input<string>('Select an option');
|
placeholder = input<string>('Select an option');
|
||||||
icon = input<string>();
|
icon = input<string>();
|
||||||
selectedValue = model<any>(null);
|
value = model<any>(null);
|
||||||
change = output<any>();
|
selectionChange = output<any>();
|
||||||
|
|
||||||
isOpen = signal(false);
|
isOpen = signal(false);
|
||||||
focusedIndex = signal(-1);
|
focusedIndex = signal(-1);
|
||||||
|
|
||||||
constructor(private el: ElementRef) {}
|
constructor(private el: ElementRef) {}
|
||||||
|
|
||||||
|
normalizedOptions = computed(() => {
|
||||||
|
return this.options().map(opt => {
|
||||||
|
if (typeof opt === 'string') {
|
||||||
|
return { label: opt, value: opt };
|
||||||
|
}
|
||||||
|
return opt;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.isOpen.update(v => !v);
|
this.isOpen.update(v => !v);
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
this.focusedIndex.set(this.options().findIndex(o => o.value === this.selectedValue()));
|
this.focusedIndex.set(this.normalizedOptions().findIndex(o => o.value === this.value()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,15 +61,34 @@ export class Dropdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectOption(option: DropdownOption) {
|
selectOption(option: DropdownOption) {
|
||||||
this.selectedValue.set(option.value);
|
this.value.set(option.value);
|
||||||
this.change.emit(option.value);
|
this.selectionChange.emit(option.value);
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onEnter(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.isOpen()) {
|
||||||
|
const currentIndex = this.focusedIndex();
|
||||||
|
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
|
||||||
|
this.selectOption(this.normalizedOptions()[currentIndex]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onSpaceDown(event: Event) {
|
onSpaceDown(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (this.isOpen()) {
|
||||||
|
const currentIndex = this.focusedIndex();
|
||||||
|
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
|
||||||
|
this.selectOption(this.normalizedOptions()[currentIndex]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onArrowDown(event: Event) {
|
onArrowDown(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -68,7 +96,7 @@ export class Dropdown {
|
|||||||
this.toggle();
|
this.toggle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.focusedIndex.update(i => (i + 1) % this.options().length);
|
this.focusedIndex.update(i => (i + 1) % this.normalizedOptions().length);
|
||||||
}
|
}
|
||||||
|
|
||||||
onArrowUp(event: Event) {
|
onArrowUp(event: Event) {
|
||||||
@@ -77,7 +105,7 @@ export class Dropdown {
|
|||||||
this.toggle();
|
this.toggle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.focusedIndex.update(i => (i - 1 + this.options().length) % this.options().length);
|
this.focusedIndex.update(i => (i - 1 + this.normalizedOptions().length) % this.normalizedOptions().length);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
@@ -87,8 +115,8 @@ export class Dropdown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectedLabel(): string {
|
selectedLabel = computed(() => {
|
||||||
const selected = this.options().find(o => o.value === this.selectedValue());
|
const selected = this.normalizedOptions().find(o => o.value === this.value());
|
||||||
return selected ? selected.label : this.placeholder();
|
return selected ? selected.label : this.placeholder();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
@import url('https://aef.kr/static/fontstyles/nanum-square-neo.css');
|
@import url('https://aef.kr/static/fontstyles/nanum-square-neo.css');
|
||||||
|
@import url("https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.3/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css");
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'NanumSquareNeo', sans-serif;
|
font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Wanted Sans', sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user