feat: add Select and SelectOption components with styles and integration into index page

This commit is contained in:
2026-06-16 14:10:49 +02:00
parent 03a92db05e
commit 8c1e36aaa7
6 changed files with 295 additions and 1 deletions
+71
View File
@@ -0,0 +1,71 @@
@use '../../styles/tokens/typography' as *;
.select {
position: relative;
display: inline-flex;
flex-direction: column;
width: 100%;
&__trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--nds-spacing-xs) var(--nds-spacing-md);
background-color: var(--nds-neutral);
color: var(--nds-text);
border: none;
border-radius: var(--nds-radius-sm);
cursor: pointer;
width: 100%;
gap: var(--nds-spacing-xs);
&:hover {
background-color: color-mix(in srgb, var(--nds-neutral) 85%, var(--nds-text));
}
}
&__label {
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__arrow {
flex-shrink: 0;
display: flex;
align-items: center;
&--hidden {
display: none;
}
}
&__dropdown {
position: absolute;
top: calc(100% + var(--nds-spacing-2xs));
left: 0;
right: 0;
background-color: var(--nds-neutral);
border-radius: var(--nds-radius-sm);
list-style: none;
margin: 0;
padding: 0;
z-index: 100;
overflow: hidden;
&[hidden] {
display: none;
}
}
&--disabled {
opacity: 0.5;
.select__trigger {
background-color: var(--nds-disabled);
cursor: not-allowed;
}
}
}
+32
View File
@@ -0,0 +1,32 @@
@use '../../styles/tokens/typography' as *;
.select-option {
padding: var(--nds-spacing-xs) var(--nds-spacing-md);
cursor: pointer;
color: var(--nds-text);
background-color: var(--nds-neutral);
transition: background-color 0.15s ease;
&:hover:not(.select-option__disabled) {
background-color: var(--nds-secondary);
}
&:active {
background-color: var(--nds-accent);
}
&__selected {
background-color: var(--nds-primary);
color: var(--nds-text);
&:hover {
background-color: color-mix(in srgb, var(--nds-primary) 85%, var(--nds-background));
}
}
&__disabled {
background-color: var(--nds-disabled);
color: color-mix(in srgb, var(--nds-text) 40%, transparent);
cursor: not-allowed;
}
}
+136
View File
@@ -0,0 +1,136 @@
---
import Arrow2 from '../Icons/Arrow2/Arrow2.astro';
export interface Props {
default?: string;
disabled?: boolean;
value?: string;
}
const { default: defaultValue, disabled = false, value } = Astro.props;
---
<nds-select
data-default={defaultValue}
data-value={value}
data-disabled={disabled ? 'true' : undefined}
class={`select ${disabled ? 'select--disabled' : ''}`}
>
<button
class="select__trigger"
type="button"
aria-haspopup="listbox"
aria-expanded="false"
disabled={disabled}
>
<span class="select__label"></span><span class="select__arrow select__arrow--closed">
<Arrow2 size={16} orientation="left" />
</span>
<span class="select__arrow select__arrow--open select__arrow--hidden">
<Arrow2 size={16} orientation="down" />
</span>
</button>
<ul class="select__dropdown" role="listbox">
<slot />
</ul>
</nds-select>
<style lang="scss">
@use './select.scss';
</style>
<script>
class NdsSelect extends HTMLElement {
private _value: string | null = null;
connectedCallback() {
const trigger = this.querySelector<HTMLButtonElement>('.select__trigger')!;
requestAnimationFrame(() => {
const options = this.getOptions();
const initial = this.dataset.value ?? this.dataset.default ?? null;
const match = options.find(o => o.dataset.value === initial);
this.select(match ?? options[0] ?? null, { silent: true });
trigger.addEventListener('click', () => this.toggle());
document.addEventListener('click', (e) => {
if (!this.contains(e.target as Node)) this.close();
});
this.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.close();
});
const observer = new MutationObserver(() => {
const newVal = this.dataset.value ?? null;
if (newVal !== this._value) {
const match = this.getOptions().find(o => o.dataset.value === newVal);
this.select(match ?? this.getOptions()[0] ?? null, { silent: true });
}
});
observer.observe(this, { attributes: true, attributeFilter: ['data-value'] });
});
}
getOptions(): HTMLElement[] {
return Array.from(this.querySelectorAll<HTMLElement>('[data-nds-option]'));
}
select(option: HTMLElement | null, { silent = false } = {}) {
if (!option) return;
this.getOptions().forEach(o => {
o.setAttribute('aria-selected', 'false');
o.classList.remove('select-option__selected');
});
option.setAttribute('aria-selected', 'true');
option.classList.add('select-option__selected');
const value = option.dataset.value ?? option.textContent?.trim() ?? '';
const label = option.textContent?.trim() ?? '';
this._value = value;
const labelEl = this.querySelector<HTMLElement>('.select__label');
if (labelEl) labelEl.textContent = label;
this.close();
if (!silent) {
this.dispatchEvent(new CustomEvent('nds:change', {
detail: { value, label },
bubbles: true,
}));
}
}
toggle() {
this.isOpen() ? this.close() : this.open();
}
open() {
this.classList.add('select--open');
this.querySelector('.select__trigger')?.setAttribute('aria-expanded', 'true');
this.querySelector('.select__dropdown')?.removeAttribute('hidden');
this.querySelector('.select__arrow--closed')?.classList.add('select__arrow--hidden');
this.querySelector('.select__arrow--open')?.classList.remove('select__arrow--hidden');
}
close() {
this.classList.remove('select--open');
this.querySelector('.select__trigger')?.setAttribute('aria-expanded', 'false');
this.querySelector('.select__dropdown')?.setAttribute('hidden', '');
this.querySelector('.select__arrow--closed')?.classList.remove('select__arrow--hidden');
this.querySelector('.select__arrow--open')?.classList.add('select__arrow--hidden');
}
isOpen() {
return this.classList.contains('select--open');
}
}
customElements.define('nds-select', NdsSelect);
</script>
+32
View File
@@ -0,0 +1,32 @@
---
export interface Props {
value: string;
disabled?: boolean;
}
const { value, disabled = false } = Astro.props;
---
<li
data-nds-option
data-value={value}
role="option"
aria-selected="false"
aria-disabled={disabled ? 'true' : undefined}
class={`select-option ${disabled ? 'select-option__disabled' : ''}`}
>
<slot />
</li>
<script>
document.addEventListener('click', (e) => {
const option = (e.target as HTMLElement).closest<HTMLElement>('[data-nds-option]');
if (!option || option.getAttribute('aria-disabled') === 'true') return;
const select = option.closest<any>('nds-select');
select?.select(option);
});
</script>
<style lang="scss">
@use './selectOption.scss';
</style>
+3 -1
View File
@@ -13,4 +13,6 @@ export { default as ListItemTitle } from './ListItem/listItemTitle.astro';
export { default as ListItemSubtitle } from './ListItem/listItemSubtitle.astro'; export { default as ListItemSubtitle } from './ListItem/listItemSubtitle.astro';
export { default as LoadingBar } from './LoadingBar/loadingBar.astro'; export { default as LoadingBar } from './LoadingBar/loadingBar.astro';
export { default as NumericStepper } from './numericStepper/numericStepper.astro'; export { default as NumericStepper } from './numericStepper/numericStepper.astro';
export { default as Avatar } from './Avatar/avatar.astro'; export { default as Avatar } from './Avatar/avatar.astro';
export { default as Select } from './Select/select.astro';
export { default as SelectOption } from './Select/selectOption.astro';
+21
View File
@@ -20,6 +20,8 @@ import {
SearchIcon, SettingsIcon, ShareIcon, ShieldIcon, SortIcon, StatsIcon, SearchIcon, SettingsIcon, ShareIcon, ShieldIcon, SortIcon, StatsIcon,
UploadIcon, UploadIcon,
} from '../components/Icons/index.ts'; } from '../components/Icons/index.ts';
import Select from '../components/Select/select.astro';
import SelectOption from '../components/Select/selectOption.astro';
const initialChecked = true; const initialChecked = true;
--- ---
@@ -27,6 +29,25 @@ const initialChecked = true;
<Layout> <Layout>
<main> <main>
<h1>Nova Design System</h1> <h1>Nova Design System</h1>
<section>
<h2>Select</h2>
<div style="display: flex; flex-direction: column; gap: var(--nds-spacing-md);">
<Select default="option3">
<SelectOption value="option1">Option 1</SelectOption>
<SelectOption value="option2">Option 2</SelectOption>
<SelectOption value="option3" >Option 3</SelectOption>
<SelectOption value="option4" >Option 4</SelectOption>
<SelectOption value="option5" >Option 5</SelectOption>
<SelectOption value="option6" >Option 6</SelectOption>
<SelectOption value="option7" >Option 7</SelectOption>
<SelectOption value="option8" >Option 8</SelectOption>
<SelectOption value="option9" disabled>Option 9</SelectOption>
</Select>
<Select disabled>
<SelectOption value="option1">Option 1</SelectOption>
</Select>
</div>
</section>
<section> <section>
<h2>Avatar</h2> <h2>Avatar</h2>
<div style="display: flex; flex-direction: column; gap: var(--nds-spacing-md);"> <div style="display: flex; flex-direction: column; gap: var(--nds-spacing-md);">