feat: add Select and SelectOption components with styles and integration into index page
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -14,3 +14,5 @@ export { default as ListItemSubtitle } from './ListItem/listItemSubtitle.astro';
|
||||
export { default as LoadingBar } from './LoadingBar/loadingBar.astro';
|
||||
export { default as NumericStepper } from './numericStepper/numericStepper.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';
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
SearchIcon, SettingsIcon, ShareIcon, ShieldIcon, SortIcon, StatsIcon,
|
||||
UploadIcon,
|
||||
} from '../components/Icons/index.ts';
|
||||
import Select from '../components/Select/select.astro';
|
||||
import SelectOption from '../components/Select/selectOption.astro';
|
||||
|
||||
const initialChecked = true;
|
||||
---
|
||||
@@ -27,6 +29,25 @@ const initialChecked = true;
|
||||
<Layout>
|
||||
<main>
|
||||
<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>
|
||||
<h2>Avatar</h2>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--nds-spacing-md);">
|
||||
|
||||
Reference in New Issue
Block a user