357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import {
|
|
customElement,
|
|
type TemplateResult,
|
|
property,
|
|
html,
|
|
css,
|
|
cssManager,
|
|
} from '@design.estate/dees-element';
|
|
import { DeesInputBase } from './dees-input-base.js';
|
|
import { demoFunc } from './dees-input-radiogroup.demo.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'dees-input-radiogroup': DeesInputRadiogroup;
|
|
}
|
|
}
|
|
|
|
type RadioOption = string | { option: string; key: string; payload?: any };
|
|
|
|
@customElement('dees-input-radiogroup')
|
|
export class DeesInputRadiogroup extends DeesInputBase<string | object> {
|
|
public static demo = demoFunc;
|
|
|
|
// INSTANCE
|
|
|
|
@property({ type: Array })
|
|
public options: RadioOption[] = [];
|
|
|
|
@property()
|
|
public selectedOption: string = '';
|
|
|
|
@property({ type: String })
|
|
public direction: 'vertical' | 'horizontal' = 'vertical';
|
|
|
|
@property({ type: String, reflect: true })
|
|
public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
|
|
|
|
// Form compatibility
|
|
public get value() {
|
|
const option = this.getOptionByKey(this.selectedOption);
|
|
if (typeof option === 'object' && option.payload !== undefined) {
|
|
return option.payload;
|
|
}
|
|
return this.selectedOption;
|
|
}
|
|
|
|
public set value(val: string | any) {
|
|
if (typeof val === 'string') {
|
|
this.selectedOption = val;
|
|
} else {
|
|
// Try to find option by payload
|
|
const option = this.options.find(opt =>
|
|
typeof opt === 'object' && opt.payload === val
|
|
);
|
|
if (option && typeof option === 'object') {
|
|
this.selectedOption = option.key;
|
|
}
|
|
}
|
|
}
|
|
|
|
public static styles = [
|
|
...DeesInputBase.baseStyles,
|
|
cssManager.defaultStyles,
|
|
css`
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:host {
|
|
display: block;
|
|
position: relative;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
|
}
|
|
|
|
.maincontainer {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.maincontainer.horizontal {
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
gap: 20px;
|
|
}
|
|
|
|
.radio-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 6px 0;
|
|
cursor: pointer;
|
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
user-select: none;
|
|
position: relative;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.maincontainer.horizontal .radio-option {
|
|
padding: 6px 20px 6px 0;
|
|
}
|
|
|
|
.radio-option:hover .radio-circle {
|
|
border-color: ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
|
|
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
|
|
}
|
|
|
|
.radio-option:hover .radio-label {
|
|
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
|
}
|
|
|
|
.radio-circle {
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
border: 2px solid ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
|
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 6.8%)')};
|
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.radio-option.selected .radio-circle {
|
|
border-color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
|
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
|
}
|
|
|
|
.radio-option.selected .radio-circle::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 6.8%)')};
|
|
transform: scale(0);
|
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.radio-option.selected .radio-circle::after {
|
|
transform: scale(1);
|
|
}
|
|
|
|
.radio-circle:focus-visible {
|
|
outline: none;
|
|
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 3.9%)')},
|
|
0 0 0 4px ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
|
}
|
|
|
|
.radio-label {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
|
|
transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
letter-spacing: -0.006em;
|
|
line-height: 20px;
|
|
}
|
|
|
|
.radio-option.selected .radio-label {
|
|
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
|
}
|
|
|
|
:host([disabled]) .radio-option {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
:host([disabled]) .radio-option:hover .radio-circle {
|
|
border-color: ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
|
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 6.8%)')};
|
|
}
|
|
|
|
:host([disabled]) .radio-option:hover .radio-label {
|
|
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
|
|
}
|
|
|
|
.label-text {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
|
margin-bottom: 10px;
|
|
letter-spacing: -0.006em;
|
|
line-height: 20px;
|
|
}
|
|
|
|
.description-text {
|
|
font-size: 13px;
|
|
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
|
margin-top: 10px;
|
|
line-height: 1.5;
|
|
letter-spacing: -0.003em;
|
|
}
|
|
|
|
/* Validation styles */
|
|
:host([validationState="invalid"]) .radio-circle {
|
|
border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
|
}
|
|
|
|
:host([validationState="invalid"]) .radio-option.selected .radio-circle {
|
|
border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
|
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
|
}
|
|
|
|
:host([validationState="valid"]) .radio-option.selected .radio-circle {
|
|
border-color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
|
|
background: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
|
|
}
|
|
|
|
:host([validationState="warn"]) .radio-option.selected .radio-circle {
|
|
border-color: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')};
|
|
background: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')};
|
|
}
|
|
|
|
/* Override base grid layout for radiogroup to prevent large gaps */
|
|
:host([label-position="left"]) .input-wrapper {
|
|
grid-template-columns: auto auto;
|
|
}
|
|
|
|
:host([label-position="right"]) .input-wrapper {
|
|
grid-template-columns: auto auto;
|
|
}
|
|
`,
|
|
];
|
|
|
|
public render(): TemplateResult {
|
|
return html`
|
|
<div class="input-wrapper">
|
|
${this.label ? html`<div class="label-text">${this.label}</div>` : ''}
|
|
<div class="maincontainer ${this.direction}">
|
|
${this.options.map((option) => {
|
|
const optionKey = this.getOptionKey(option);
|
|
const optionLabel = this.getOptionLabel(option);
|
|
const isSelected = this.selectedOption === optionKey;
|
|
|
|
return html`
|
|
<div
|
|
class="radio-option ${isSelected ? 'selected' : ''}"
|
|
@click="${() => this.selectOption(optionKey)}"
|
|
@keydown="${(e: KeyboardEvent) => this.handleKeydown(e, optionKey)}"
|
|
>
|
|
<div
|
|
class="radio-circle"
|
|
tabindex="${this.disabled ? '-1' : '0'}"
|
|
role="radio"
|
|
aria-checked="${isSelected}"
|
|
aria-label="${optionLabel}"
|
|
></div>
|
|
<div class="radio-label">${optionLabel}</div>
|
|
</div>
|
|
`;
|
|
})}
|
|
</div>
|
|
${this.description ? html`<div class="description-text">${this.description}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private getOptionKey(option: RadioOption): string {
|
|
if (typeof option === 'string') {
|
|
return option;
|
|
}
|
|
return option.key;
|
|
}
|
|
|
|
private getOptionLabel(option: RadioOption): string {
|
|
if (typeof option === 'string') {
|
|
return option;
|
|
}
|
|
return option.option;
|
|
}
|
|
|
|
private getOptionByKey(key: string): RadioOption | undefined {
|
|
return this.options.find(opt => this.getOptionKey(opt) === key);
|
|
}
|
|
|
|
private selectOption(key: string): void {
|
|
if (this.disabled) {
|
|
return;
|
|
}
|
|
|
|
const oldValue = this.selectedOption;
|
|
this.selectedOption = key;
|
|
|
|
if (oldValue !== key) {
|
|
this.dispatchEvent(new CustomEvent('change', {
|
|
detail: { value: this.value },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
|
|
this.dispatchEvent(new CustomEvent('input', {
|
|
detail: { value: this.value },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
|
|
this.changeSubject.next(this);
|
|
}
|
|
}
|
|
|
|
public getValue(): string | any {
|
|
return this.value;
|
|
}
|
|
|
|
public setValue(val: string | any): void {
|
|
this.value = val;
|
|
}
|
|
|
|
public async validate(): Promise<boolean> {
|
|
if (this.required && !this.selectedOption) {
|
|
this.validationState = 'invalid';
|
|
return false;
|
|
}
|
|
|
|
this.validationState = 'valid';
|
|
return true;
|
|
}
|
|
|
|
public async firstUpdated() {
|
|
// Auto-select first option if none selected and not required
|
|
if (!this.selectedOption && this.options.length > 0 && !this.required) {
|
|
const firstOption = this.options[0];
|
|
this.selectedOption = this.getOptionKey(firstOption);
|
|
}
|
|
}
|
|
|
|
private handleKeydown(event: KeyboardEvent, optionKey: string) {
|
|
if (this.disabled) return;
|
|
|
|
if (event.key === ' ' || event.key === 'Enter') {
|
|
event.preventDefault();
|
|
this.selectOption(optionKey);
|
|
} else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
event.preventDefault();
|
|
this.focusNextOption();
|
|
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
|
event.preventDefault();
|
|
this.focusPreviousOption();
|
|
}
|
|
}
|
|
|
|
private focusNextOption() {
|
|
const radioCircles = Array.from(this.shadowRoot.querySelectorAll('.radio-circle'));
|
|
const currentIndex = radioCircles.findIndex(el => el === this.shadowRoot.activeElement);
|
|
const nextIndex = (currentIndex + 1) % radioCircles.length;
|
|
(radioCircles[nextIndex] as HTMLElement).focus();
|
|
}
|
|
|
|
private focusPreviousOption() {
|
|
const radioCircles = Array.from(this.shadowRoot.querySelectorAll('.radio-circle'));
|
|
const currentIndex = radioCircles.findIndex(el => el === this.shadowRoot.activeElement);
|
|
const prevIndex = currentIndex <= 0 ? radioCircles.length - 1 : currentIndex - 1;
|
|
(radioCircles[prevIndex] as HTMLElement).focus();
|
|
}
|
|
} |