🎨 🚀 Overhaul UI/UX with comprehensive design system improvements

 Features added:

- 🔍 Implement functional search in header navigation
- ⚙️ Add basic user settings page
- 📱 Make dashboard fully responsive

🔧 Enhancements:

- 🎭 Standardize CSS with consistent theming across components
- 🧹 Remove unused CSS for better performance
- 📊 Improve dashboard layout and visual hierarchy
- 📦 Redesign last block widget for better usability

💅 This commit introduces a cohesive design system with functional design-token components for a more  polished user experience.
This commit is contained in:
Danny Morabito 2025-03-20 09:46:13 +01:00
parent 5afeb4d01a
commit dc9abee715
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
49 changed files with 4176 additions and 2468 deletions

View file

@ -0,0 +1,257 @@
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('arx-button')
export class StyledButton extends LitElement {
@property() label = '';
@property() type: 'button' | 'submit' | 'reset' | 'menu' = 'button';
@property({ type: Boolean }) disabled = false;
@property() name = '';
@property() value = '';
@property() variant: 'default' | 'primary' | 'secondary' | 'accent' = 'default';
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) fullWidth = false;
@property({ type: Boolean, reflect: true }) pressed = false;
@property({ type: String }) href = '';
static override styles = css`
:host {
display: inline-block;
position: relative;
}
:host([fullWidth]) {
display: block;
width: 100%;
}
button {
--bg: var(--color-base-100);
--text: var(--color-base-content);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 46px;
padding: 10px 24px;
font-size: 16px;
font-weight: 500;
font-family: inherit;
color: var(--text);
background: var(--bg);
border: var(--border) solid var(--color-base-300);
border-radius: var(--radius-selector);
cursor: pointer;
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: calc(var(--depth) * 3px) calc(var(--depth) * 3px)
calc(var(--depth) * 6px)
oklch(from var(--color-base-content) l c h / 0.15),
calc(var(--depth) * -3px) calc(var(--depth) * -3px)
calc(var(--depth) * 6px) oklch(from var(--color-base-100) l c h / 0.7);
overflow: hidden;
outline: none;
}
:host([variant="primary"]) button {
--bg: var(--color-primary);
--text: var(--color-primary-content);
}
:host([variant="secondary"]) button {
--bg: var(--color-secondary);
--text: var(--color-secondary-content);
}
:host([variant="accent"]) button {
--bg: var(--color-accent);
--text: var(--color-accent-content);
}
:host([fullWidth]) button {
width: 100%;
}
button:hover,
button:focus-visible {
transform: translateY(-2px);
background: oklch(from var(--bg) calc(l + 0.02) c h);
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
calc(var(--depth) * 8px)
oklch(from var(--color-base-content) l c h / 0.2),
calc(var(--depth) * -4px) calc(var(--depth) * -4px)
calc(var(--depth) * 8px) oklch(from var(--color-base-100) l c h / 0.8);
}
button:active,
:host([pressed]) button {
transform: translateY(1px);
border-top: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-left: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-bottom: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
border-right: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
box-shadow: inset calc(var(--depth) * 3px) calc(var(--depth) * 3px)
calc(var(--depth) * 5px)
oklch(from var(--color-base-content) l c h / 0.2),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 3px) oklch(from var(--color-base-100) l c h / 0.5);
background: linear-gradient(
145deg,
oklch(from var(--bg) calc(l - 0.03) c h),
oklch(from var(--bg) l c h)
);
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
box-shadow: calc(var(--depth) * 1px) calc(var(--depth) * 1px)
calc(var(--depth) * 3px)
oklch(from var(--color-base-content) l c h / 0.1),
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 3px) oklch(from var(--color-base-100) l c h / 0.4);
}
.spinner {
display: none;
position: relative;
width: 18px;
height: 18px;
}
:host([loading]) .spinner {
display: inline-block;
}
:host([loading]) .label {
opacity: 0.7;
}
.spinner::before {
content: "";
box-sizing: border-box;
position: absolute;
top: 0;
left: 0;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: currentColor;
border-right-color: currentColor;
animation: spinner 0.8s linear infinite;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
::slotted([slot="prefix"]) {
margin-right: 4px;
}
::slotted([slot="suffix"]) {
margin-left: 4px;
}
`;
override render() {
return html`
<button
type=${this.type}
?disabled=${this.disabled || this.loading}
name=${this.name}
value=${this.value}
@click=${this._handleClick}
@keydown=${this._handleKeyDown}
@focus=${this._handleFocus}
@blur=${this._handleBlur}
>
<slot name="prefix"></slot>
<div class="spinner"></div>
<slot name="label">
<span class="label">${this.label}</span>
</slot>
<slot name="suffix"></slot>
</button>
`;
}
private _handleClick(e: MouseEvent) {
if (this.disabled || this.loading) {
e.preventDefault();
return;
}
if (this.href) {
let href = this.href;
if (href.startsWith('javascript:')) return href;
if (href.startsWith('eve://')) href = href.replace('eve://', '#');
if (href.startsWith('/')) href = href.replace('/', '#');
if (!href.startsWith('#')) href = `#${href}`;
window.location.href = href;
return;
}
this.dispatchEvent(
new CustomEvent('click', {
detail: {
name: this.name,
value: this.value,
},
bubbles: true,
composed: true,
}),
);
}
private _handleKeyDown(e: KeyboardEvent) {
if ((e.key === 'Enter' || e.key === ' ') && !this.disabled && !this.loading) {
e.preventDefault();
this.dispatchEvent(
new CustomEvent('click', {
detail: {
name: this.name,
value: this.value,
},
bubbles: true,
composed: true,
}),
);
}
this.dispatchEvent(
new CustomEvent('keydown', {
detail: { key: e.key },
bubbles: true,
composed: true,
}),
);
}
private _handleFocus() {
this.dispatchEvent(
new CustomEvent('focus', {
bubbles: true,
composed: true,
}),
);
}
private _handleBlur() {
this.dispatchEvent(
new CustomEvent('blur', {
bubbles: true,
composed: true,
}),
);
}
}

View file

@ -0,0 +1,35 @@
import { LitElement, css, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('arx-card')
export class StyledFieldset extends LitElement {
static override styles = css`
:host {
display: block;
background: var(--color-base-200);
border-radius: var(--radius-box);
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
calc(var(--depth) * 8px)
oklch(from var(--color-base-content) l c h / 0.1),
calc(var(--depth) * -2px) calc(var(--depth) * -2px)
calc(var(--depth) * 6px) oklch(from var(--color-base-100) l c h / 0.6);
padding: 32px;
margin-bottom: 32px;
border: var(--border) solid var(--color-base-300);
transition: all 0.3s ease;
&:hover {
box-shadow: calc(var(--depth) * 6px) calc(var(--depth) * 6px)
calc(var(--depth) * 12px)
oklch(from var(--color-base-content) l c h / 0.15),
calc(var(--depth) * -3px) calc(var(--depth) * -3px)
calc(var(--depth) * 8px)
oklch(from var(--color-base-100) l c h / 0.7);
transform: translateY(-2px);
}
}
`;
override render() {
return html`<slot></slot>`;
}
}

View file

@ -0,0 +1,122 @@
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
@customElement('arx-fieldset')
export class StyledFieldset extends LitElement {
@property() legend = '';
@property({ type: Boolean }) disabled = false;
@property() variant = 'default'; // default, primary, secondary, accent
static override styles = css`
:host {
display: block;
width: 100%;
margin-bottom: 24px;
}
.fieldset-container {
background: var(--color-base-100);
border: var(--border) solid var(--color-base-300);
border-radius: var(--radius-box);
padding: 24px;
position: relative;
box-shadow: calc(var(--depth) * 2px) calc(var(--depth) * 2px)
calc(var(--depth) * 4px)
oklch(from var(--color-base-content) l c h / 0.1),
calc(var(--depth) * -2px) calc(var(--depth) * -2px)
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.5);
transition: all 0.25s ease;
}
:host([variant="primary"]) .fieldset-container {
border-left: calc(var(--border) * 3) solid var(--color-primary);
}
:host([variant="secondary"]) .fieldset-container {
border-left: calc(var(--border) * 3) solid var(--color-secondary);
}
:host([variant="accent"]) .fieldset-container {
border-left: calc(var(--border) * 3) solid var(--color-accent);
}
.legend {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
background: var(--color-base-100);
border: var(--border) solid var(--color-base-300);
padding: 0 10px;
font-size: 14px;
font-weight: 600;
color: var(--color-base-content);
border-radius: calc(var(--radius-selector) / 2);
transition: all 0.25s ease;
}
:host([variant="primary"]) .legend {
color: var(--color-primary);
}
:host([variant="secondary"]) .legend {
color: var(--color-secondary);
}
:host([variant="accent"]) .legend {
color: var(--color-accent);
}
.fieldset-content {
padding-top: 8px;
}
:host([disabled]) .fieldset-container {
opacity: 0.7;
cursor: not-allowed;
box-shadow: calc(var(--depth) * 1px) calc(var(--depth) * 1px)
calc(var(--depth) * 2px)
oklch(from var(--color-base-content) l c h / 0.05),
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 2px) oklch(from var(--color-base-100) l c h / 0.3);
}
:host([disabled]) .legend {
opacity: 0.7;
}
:host(:not([disabled])) .fieldset-container:hover {
box-shadow: calc(var(--depth) * 3px) calc(var(--depth) * 3px)
calc(var(--depth) * 6px)
oklch(from var(--color-base-content) l c h / 0.15),
calc(var(--depth) * -3px) calc(var(--depth) * -3px)
calc(var(--depth) * 6px) oklch(from var(--color-base-100) l c h / 0.6);
}
:host(:not([disabled])) .fieldset-container:focus-within {
border-color: var(--color-accent);
box-shadow: calc(var(--depth) * 2px) calc(var(--depth) * 2px)
calc(var(--depth) * 4px)
oklch(from var(--color-base-content) l c h / 0.1),
calc(var(--depth) * -2px) calc(var(--depth) * -2px)
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.5),
0 0 0 2px oklch(from var(--color-accent) l c h / 0.2);
}
:host(:not([disabled])) .fieldset-container:focus-within .legend {
color: var(--color-accent);
}
`;
override render() {
return html`
<div class="fieldset-container" ?disabled=${this.disabled}>
${when(this.legend, () => html`<div class="legend">${this.legend}</div>`)}
<div class="fieldset-content">
<slot></slot>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,165 @@
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
@customElement('arx-input')
export class StyledInput extends LitElement {
@property() placeholder = '';
@property() value = '';
@property({ type: Boolean }) disabled = false;
@property() type = 'text';
@property() name = '';
@property({ type: Boolean }) required = false;
@property() label = '';
static override styles = css`
:host {
display: inline-block;
width: 100%;
margin-bottom: 8px;
}
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: var(--color-base-content);
font-weight: 500;
}
input {
width: 100%;
padding: 14px 16px;
font-size: 16px;
color: var(--color-base-content);
background: var(--color-base-100);
border: var(--border) solid var(--color-base-300);
border-radius: var(--radius-field);
box-shadow: inset calc(var(--depth) * 2px) calc(var(--depth) * 2px)
calc(var(--depth) * 4px)
oklch(from var(--color-base-content) l c h / 0.15),
inset calc(var(--depth) * -2px) calc(var(--depth) * -2px)
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.7),
0 0 0 transparent;
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
input::placeholder {
color: var(--color-secondary);
transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1.1);
}
input:hover {
transform: translateY(-1px);
background: oklch(from var(--color-base-100) calc(l + 0.02) c h);
}
input:hover::placeholder {
transform: translateX(3px);
}
input:focus {
outline: none;
transform: translateY(1px);
border-top: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-left: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-bottom: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
border-right: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
box-shadow: inset calc(var(--depth) * 3px) calc(var(--depth) * 3px)
calc(var(--depth) * 5px)
oklch(from var(--color-base-content) l c h / 0.2),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 3px) oklch(from var(--color-base-100) l c h / 0.5),
0 0 0 transparent;
background: linear-gradient(
145deg,
oklch(from var(--color-base-100) calc(l - 0.03) c h),
oklch(from var(--color-base-100) l c h)
);
}
input:disabled {
opacity: 0.7;
cursor: not-allowed;
box-shadow: inset calc(var(--depth) * 1px) calc(var(--depth) * 1px)
calc(var(--depth) * 2px)
oklch(from var(--color-base-content) l c h / 0.05),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 2px) oklch(from var(--color-base-100) l c h / 0.4);
transform: none;
}
`;
override render() {
return html`
${when(this.label, () => html`<label for="input-${this.name}">${this.label}</label>`)}
<input
.value=${this.value}
?disabled=${this.disabled}
?required=${this.required}
placeholder=${this.placeholder}
type=${this.type}
name=${this.name}
@input=${this._handleInput}
@focus=${this._handleFocus}
@blur=${this._handleBlur}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
`;
}
private _handleKeyDown(e: KeyboardEvent) {
this.dispatchEvent(
new CustomEvent('keydown', {
detail: { key: e.key },
bubbles: true,
composed: true,
}),
);
}
private _handleKeyUp(e: KeyboardEvent) {
this.dispatchEvent(
new CustomEvent('keyup', {
detail: { key: e.key },
bubbles: true,
composed: true,
}),
);
}
private _handleFocus() {
this.dispatchEvent(
new CustomEvent('focus', {
bubbles: true,
composed: true,
}),
);
}
private _handleBlur() {
this.dispatchEvent(
new CustomEvent('blur', {
bubbles: true,
composed: true,
}),
);
}
private _handleInput(e: InputEvent) {
const input = e.target as HTMLInputElement;
this.value = input.value;
this.dispatchEvent(
new CustomEvent('input', {
detail: { value: this.value },
bubbles: true,
composed: true,
}),
);
}
}

View file

@ -0,0 +1,206 @@
import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import '@components/General/Input';
@customElement('arx-prompt')
export class EvePrompt extends LitElement {
@property({ type: String }) promptText = 'Please provide input';
@property({ type: String }) cancelText = 'Cancel';
@property({ type: String }) saveText = 'Save';
@property({ type: Boolean }) open = false;
@property({ type: Boolean }) showInput = false;
@property({ type: String }) placeholder = 'Enter your response';
@property({ type: String }) defaultValue = '';
@state() private _inputValue = '';
private _previousFocus: HTMLElement | null = null;
static override styles = css`
:host {
display: block;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: oklch(from var(--color-base-content) l c h / 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
backdrop-filter: blur(4px);
}
.overlay.active {
opacity: 1;
pointer-events: all;
}
.prompt-container {
background-color: var(--color-base-100);
border-radius: var(--radius-box);
border: var(--border) solid var(--color-base-300);
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
calc(var(--depth) * 8px)
oklch(from var(--color-base-content) l c h / 0.2),
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4);
width: 90%;
max-width: 420px;
padding: 28px;
transform: scale(0.95) translateY(10px);
transition: transform 0.25s cubic-bezier(0.1, 1, 0.2, 1);
color: var(--color-base-content);
}
.overlay.active .prompt-container {
transform: scale(1) translateY(0);
}
.prompt-header {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px 0;
line-height: 1.4;
color: var(--color-base-content);
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.prompt-container:focus-within {
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
calc(var(--depth) * 8px)
oklch(from var(--color-base-content) l c h / 0.2),
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4),
0 0 0 2px oklch(from var(--color-accent) l c h / 0.2);
}
`;
constructor() {
super();
this._handleKeyDown = this._handleKeyDown.bind(this);
}
override connectedCallback() {
super.connectedCallback();
document.addEventListener('keydown', this._handleKeyDown);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keydown', this._handleKeyDown);
}
override updated(changedProps: Map<string, unknown>) {
if (changedProps.has('open') && this.open) {
this._inputValue = this.defaultValue;
this._previousFocus = document.activeElement as HTMLElement;
// Focus the input or save button after rendering
setTimeout(() => {
if (this.showInput) {
const input = this.shadowRoot?.querySelector('.input-field') as HTMLElement;
if (input) input.focus();
} else {
const saveBtn = this.shadowRoot?.querySelector('.save-btn') as HTMLElement;
if (saveBtn) saveBtn.focus();
}
}, 50);
} else if (changedProps.has('open') && !this.open && this._previousFocus) {
this._previousFocus.focus();
}
}
private _handleKeyDown(e: KeyboardEvent) {
if (!this.open) return;
if (e.key === 'Escape') this._handleCancel();
if (e.key === 'Enter' && !e.shiftKey) this._handleSave();
}
private _handleInputChange(e: Event) {
const target = e.target as HTMLInputElement;
this._inputValue = target.value;
}
private _handleCancel() {
this.open = false;
this.dispatchEvent(new CustomEvent('cancel'));
}
private _handleSave() {
this.open = false;
this.dispatchEvent(
new CustomEvent('save', {
detail: { value: this._inputValue },
}),
);
}
override render() {
return html`
<div
class="${classMap({ overlay: true, active: this.open })}"
@click="${(e: MouseEvent) => e.target === e.currentTarget && this._handleCancel()}"
>
<div class="prompt-container">
<div class="prompt-header">${this.promptText}</div>
${
this.showInput
? html`
<arx-input
type="text"
class="input-field"
.value=${this._inputValue}
@input=${this._handleInputChange}
placeholder=${this.placeholder}
></arx-input>
`
: ''
}
<div class="buttons">
<arx-button
variant="accent"
label=${this.cancelText}
@click=${this._handleCancel}
>
</arx-button>
<arx-button
variant="secondary"
label=${this.saveText}
@click=${this._handleSave}
>
</arx-button>
</div>
</div>
</div>
`;
}
show() {
this.open = true;
}
hide() {
this.open = false;
}
getValue(): string {
return this._inputValue;
}
}

View file

@ -0,0 +1,207 @@
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { map } from 'lit/directives/map.js';
import { when } from 'lit/directives/when.js';
@customElement('arx-select')
export class StyledSelect<T> extends LitElement {
@property() placeholder = '';
@property() value = '';
@property({ type: Boolean }) disabled = false;
@property() name = '';
@property({ type: Boolean }) required = false;
@property() label = '';
@property({ type: Array<T> }) options = [];
@property() valueMapper?: (option: T) => string;
@property() textMapper?: (option: T) => string;
static override styles = css`
:host {
display: inline-block;
width: 100%;
margin-bottom: 8px;
}
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: var(--color-base-content);
font-weight: 500;
}
select {
width: 100%;
padding: 14px 16px;
font-size: 16px;
color: var(--color-base-content);
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: var(--color-base-100);
border: var(--border) solid var(--color-base-300);
border-radius: var(--radius-field);
box-shadow: inset calc(var(--depth) * 2px) calc(var(--depth) * 2px)
calc(var(--depth) * 4px)
oklch(from var(--color-base-content) l c h / 0.15),
inset calc(var(--depth) * -2px) calc(var(--depth) * -2px)
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.7),
0 0 0 transparent;
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 16px center;
padding-right: 40px;
}
select:hover {
transform: translateY(-1px);
background-color: oklch(from var(--color-base-100) calc(l + 0.02) c h);
}
select:focus {
outline: none;
transform: translateY(1px);
border-top: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-left: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-bottom: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
border-right: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
box-shadow: inset calc(var(--depth) * 3px) calc(var(--depth) * 3px)
calc(var(--depth) * 5px)
oklch(from var(--color-base-content) l c h / 0.2),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 3px) oklch(from var(--color-base-100) l c h / 0.5),
0 0 0 transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 16px center;
background-color: linear-gradient(
145deg,
oklch(from var(--color-base-100) calc(l - 0.03) c h),
oklch(from var(--color-base-100) l c h)
);
}
select:disabled {
opacity: 0.7;
cursor: not-allowed;
box-shadow: inset calc(var(--depth) * 1px) calc(var(--depth) * 1px)
calc(var(--depth) * 2px)
oklch(from var(--color-base-content) l c h / 0.05),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 2px) oklch(from var(--color-base-100) l c h / 0.4);
transform: none;
}
select option[value=""] {
color: var(--color-secondary);
}
`;
private _getOptionValue(option: T) {
if (option === undefined || option === null) return '';
if (this.valueMapper) return this.valueMapper(option);
if (typeof option === 'object' && 'value' in option) return option.value;
return option;
}
private _getOptionText(option: T) {
if (option === undefined || option === null) return '';
if (this.textMapper) return this.textMapper(option);
if (typeof option === 'object' && 'label' in option) return option.label;
return option;
}
override render() {
return html`
${when(this.label, () => html`<label for="select-${this.name}">${this.label}</label>`)}
<select
id="select-${this.name}"
.value=${this.value}
?disabled=${this.disabled}
?required=${this.required}
name=${this.name}
@change=${this._handleChange}
@focus=${this._handleFocus}
@blur=${this._handleBlur}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
>
${when(
this.placeholder,
() =>
html`<option value="" ?selected=${!this.value} disabled>
${this.placeholder}
</option>`,
)}
${map(this.options, (option) => {
const optionValue = this._getOptionValue(option);
const optionText = this._getOptionText(option);
return html`
<option
value=${optionValue}
?selected=${optionValue === this.value}
>
${optionText}
</option>
`;
})}
</select>
`;
}
private _handleKeyDown(e: KeyboardEvent) {
this.dispatchEvent(
new CustomEvent('keydown', {
detail: { key: e.key },
bubbles: true,
composed: true,
}),
);
}
private _handleKeyUp(e: KeyboardEvent) {
this.dispatchEvent(
new CustomEvent('keyup', {
detail: { key: e.key },
bubbles: true,
composed: true,
}),
);
}
private _handleFocus() {
this.dispatchEvent(
new CustomEvent('focus', {
bubbles: true,
composed: true,
}),
);
}
private _handleBlur() {
this.dispatchEvent(
new CustomEvent('blur', {
bubbles: true,
composed: true,
}),
);
}
private _handleChange(e: Event) {
const select = e.target as HTMLSelectElement;
this.value = select.value;
this.dispatchEvent(
new CustomEvent('change', {
detail: { value: this.value },
bubbles: true,
composed: true,
}),
);
}
}

View file

@ -0,0 +1,212 @@
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
@customElement('arx-textarea')
export class StyledTextarea extends LitElement {
@property() placeholder = '';
@property() value = '';
@property({ type: Boolean }) disabled = false;
@property() name = '';
@property({ type: Boolean }) required = false;
@property() label = '';
@property() rows = 4;
@property() maxlength = '';
@property({ type: Boolean }) resizable = true;
static override styles = css`
:host {
display: block;
width: 100%;
margin-bottom: 8px;
}
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: var(--color-base-content);
font-weight: 500;
}
textarea {
width: 100%;
min-height: 80px;
padding: 14px 16px;
font-size: 16px;
color: var(--color-base-content);
font-family: inherit;
line-height: 1.5;
background: var(--color-base-100);
border: var(--border) solid var(--color-base-300);
border-radius: var(--radius-field);
box-shadow: inset calc(var(--depth) * 2px) calc(var(--depth) * 2px)
calc(var(--depth) * 4px)
oklch(from var(--color-base-content) l c h / 0.15),
inset calc(var(--depth) * -2px) calc(var(--depth) * -2px)
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.7),
0 0 0 transparent;
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
resize: vertical;
}
:host(:not([resizable])) textarea {
resize: none;
}
textarea::placeholder {
color: var(--color-secondary);
transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1.1);
}
textarea:hover {
transform: translateY(-1px);
background: oklch(from var(--color-base-100) calc(l + 0.02) c h);
}
textarea:hover::placeholder {
transform: translateX(3px);
}
textarea:focus {
outline: none;
transform: translateY(1px);
border-top: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-left: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-bottom: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
border-right: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
box-shadow: inset calc(var(--depth) * 3px) calc(var(--depth) * 3px)
calc(var(--depth) * 5px)
oklch(from var(--color-base-content) l c h / 0.2),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 3px) oklch(from var(--color-base-100) l c h / 0.5),
0 0 0 transparent;
background: linear-gradient(
145deg,
oklch(from var(--color-base-100) calc(l - 0.03) c h),
oklch(from var(--color-base-100) l c h)
);
}
textarea:disabled {
opacity: 0.7;
cursor: not-allowed;
box-shadow: inset calc(var(--depth) * 1px) calc(var(--depth) * 1px)
calc(var(--depth) * 2px)
oklch(from var(--color-base-content) l c h / 0.05),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 2px) oklch(from var(--color-base-100) l c h / 0.4);
transform: none;
}
.character-count {
display: none;
text-align: right;
margin-top: 4px;
font-size: 12px;
color: var(--color-secondary);
}
.has-maxlength .character-count {
display: block;
}
.character-count.near-limit {
color: var(--color-warning);
}
.character-count.at-limit {
color: var(--color-error);
}
`;
override render() {
const charCount = this.value?.length || 0;
const maxLen = Number.parseInt(this.maxlength) || 0;
const nearLimit = maxLen && charCount >= maxLen * 0.8;
const atLimit = maxLen && charCount >= maxLen * 0.95;
const hasMaxlength = !!this.maxlength;
return html`
${when(this.label, () => html`<label for="textarea-${this.name}">${this.label}</label>`)}
<div class="${hasMaxlength ? 'has-maxlength' : ''}">
<textarea
id="textarea-${this.name}"
.value=${this.value}
?disabled=${this.disabled}
?required=${this.required}
placeholder=${this.placeholder}
rows=${this.rows}
maxlength=${this.maxlength}
name=${this.name}
@input=${this._handleInput}
@focus=${this._handleFocus}
@blur=${this._handleBlur}
@keydown=${this._handleKeyDown}
></textarea>
<div
class="character-count ${nearLimit ? 'near-limit' : ''} ${atLimit ? 'at-limit' : ''}"
>
${charCount}/${this.maxlength}
</div>
</div>
`;
}
private _handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
const textarea = e.target as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
this.value = `${this.value.substring(0, start)}\t${this.value.substring(end)}`;
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1;
}, 0);
}
this.dispatchEvent(
new CustomEvent('keydown', {
detail: { key: e.key },
bubbles: true,
composed: true,
}),
);
}
private _handleFocus() {
this.dispatchEvent(
new CustomEvent('focus', {
bubbles: true,
composed: true,
}),
);
}
private _handleBlur() {
this.dispatchEvent(
new CustomEvent('blur', {
bubbles: true,
composed: true,
}),
);
}
private _handleInput(e: InputEvent) {
const textarea = e.target as HTMLTextAreaElement;
this.value = textarea.value;
this.dispatchEvent(
new CustomEvent('input', {
detail: { value: this.value },
bubbles: true,
composed: true,
}),
);
}
}

View file

@ -0,0 +1,216 @@
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
@customElement('arx-toggle')
export class StyledToggle extends LitElement {
@property() label = '';
@property({ type: Boolean, reflect: true }) checked = false;
@property({ type: Boolean }) disabled = false;
@property() name = '';
@property({ type: Boolean }) required = false;
@property() value = 'on';
static override styles = css`
:host {
display: inline-block;
width: 100%;
margin-bottom: 8px;
}
.toggle-container {
display: flex;
align-items: center;
gap: 12px;
}
label {
font-size: 14px;
color: var(--color-base-content);
font-weight: 500;
cursor: pointer;
}
.toggle-wrapper {
position: relative;
display: inline-block;
width: 48px;
height: 24px;
}
.toggle-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-track {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-base-100);
border-radius: 24px;
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
border: var(--border) solid var(--color-base-300);
box-shadow: inset calc(var(--depth) * 2px) calc(var(--depth) * 2px)
calc(var(--depth) * 4px)
oklch(from var(--color-base-content) l c h / 0.15),
inset calc(var(--depth) * -2px) calc(var(--depth) * -2px)
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.7);
}
.toggle-track:hover {
background-color: oklch(from var(--color-base-100) calc(l + 0.02) c h);
}
.toggle-knob {
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 2px;
background-color: var(--color-base-content);
border-radius: 50%;
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: calc(var(--depth) * 2px) calc(var(--depth) * 2px)
calc(var(--depth) * 4px)
oklch(from var(--color-base-content) l c h / 0.2),
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 3px) oklch(from var(--color-base-100) l c h / 0.3);
}
.toggle-input:checked + .toggle-track {
background-color: var(--color-base-100);
border-top: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-left: var(--border) solid
oklch(from var(--color-base-300) l c h / 0.8);
border-bottom: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
border-right: var(--border) solid
oklch(from var(--color-base-200) l c h / 0.8);
box-shadow: inset calc(var(--depth) * 3px) calc(var(--depth) * 3px)
calc(var(--depth) * 5px)
oklch(from var(--color-base-content) l c h / 0.2),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 3px) oklch(from var(--color-base-100) l c h / 0.5);
}
.toggle-input:checked + .toggle-track .toggle-knob {
transform: translateX(24px);
background-color: var(--color-accent);
}
.toggle-input:focus + .toggle-track {
outline: none;
box-shadow: inset calc(var(--depth) * 3px) calc(var(--depth) * 3px)
calc(var(--depth) * 5px)
oklch(from var(--color-base-content) l c h / 0.2),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 3px) oklch(from var(--color-base-100) l c h / 0.5),
0 0 0 2px oklch(from var(--color-accent) l c h / 0.3);
}
.toggle-input:disabled + .toggle-track {
opacity: 0.7;
cursor: not-allowed;
box-shadow: inset calc(var(--depth) * 1px) calc(var(--depth) * 1px)
calc(var(--depth) * 2px)
oklch(from var(--color-base-content) l c h / 0.05),
inset calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 2px) oklch(from var(--color-base-100) l c h / 0.4);
}
.toggle-input:disabled + .toggle-track .toggle-knob {
background-color: var(--color-secondary);
box-shadow: calc(var(--depth) * 1px) calc(var(--depth) * 1px)
calc(var(--depth) * 2px)
oklch(from var(--color-base-content) l c h / 0.1),
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
calc(var(--depth) * 2px) oklch(from var(--color-base-100) l c h / 0.2);
}
`;
override render() {
return html`
<div class="toggle-container">
${when(this.label, () => html` <label for="toggle-${this.name}">${this.label}</label> `)}
<div class="toggle-wrapper">
<input
type="checkbox"
id="toggle-${this.name}"
class="toggle-input"
.checked=${this.checked}
?disabled=${this.disabled}
?required=${this.required}
name=${this.name}
value=${this.value}
@change=${this._handleChange}
@focus=${this._handleFocus}
@blur=${this._handleBlur}
@keydown=${this._handleKeyDown}
/>
<label for="toggle-${this.name}" class="toggle-track">
<span class="toggle-knob"></span>
</label>
</div>
</div>
`;
}
private _handleChange(e: Event) {
const checkbox = e.target as HTMLInputElement;
this.checked = checkbox.checked;
this.dispatchEvent(
new CustomEvent('change', {
detail: { checked: this.checked, value: this.value },
bubbles: true,
composed: true,
}),
);
}
private _handleFocus() {
this.dispatchEvent(
new CustomEvent('focus', {
bubbles: true,
composed: true,
}),
);
}
private _handleBlur() {
this.dispatchEvent(
new CustomEvent('blur', {
bubbles: true,
composed: true,
}),
);
}
private _handleKeyDown(e: KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this.checked = !this.checked;
this.dispatchEvent(
new CustomEvent('change', {
detail: { checked: this.checked, value: this.value },
bubbles: true,
composed: true,
}),
);
}
this.dispatchEvent(
new CustomEvent('keydown', {
detail: { key: e.key },
bubbles: true,
composed: true,
}),
);
}
}

View file

@ -0,0 +1,241 @@
import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('arx-tooltip')
export class Tooltip extends LitElement {
@property() content = '';
@property() position = 'top';
@property() delay = 100; // Delay before showing tooltip (ms)
@property() offset = 8; // Distance from target element
@property() variant = 'default'; // default, primary, secondary, accent, error, warning, success
@property({ type: Boolean }) arrow = true;
@property({ type: Boolean }) allowHtml = false;
@property({ type: Boolean }) interactive = false;
@property({ type: Boolean }) disabled = false;
@state() private isVisible = false;
@state() private timer: ReturnType<typeof setTimeout> | null = null;
static override styles = css`
:host {
display: inline-block;
position: relative;
}
.tooltip-container {
position: relative;
display: inline-block;
}
.tooltip {
position: absolute;
z-index: 999;
max-width: 300px;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-field);
font-size: 0.875rem;
line-height: 1.4;
text-align: center;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s, transform 0.2s;
box-shadow: calc(var(--depth) * 2px) calc(var(--depth) * 2px)
calc(var(--depth) * 4px)
oklch(from var(--color-base-content) l c h / 0.2);
transform: scale(0.95);
transform-origin: center;
}
.tooltip.interactive {
pointer-events: auto;
}
.tooltip.visible {
opacity: 1;
visibility: visible;
transform: scale(1);
}
/* Default variant */
.tooltip.default {
background-color: var(--color-base-content);
color: var(--color-base-100);
}
/* Primary variant */
.tooltip.primary {
background-color: var(--color-primary);
color: var(--color-primary-content);
}
/* Secondary variant */
.tooltip.secondary {
background-color: var(--color-secondary);
color: var(--color-secondary-content);
}
/* Accent variant */
.tooltip.accent {
background-color: var(--color-accent);
color: var(--color-accent-content);
}
/* Error variant */
.tooltip.error {
background-color: var(--color-error);
color: var(--color-error-content);
}
/* Warning variant */
.tooltip.warning {
background-color: var(--color-warning);
color: var(--color-warning-content);
}
/* Success variant */
.tooltip.success {
background-color: var(--color-success);
color: var(--color-success-content);
}
/* Positions */
.tooltip.top {
bottom: calc(100% + var(--offset, 8px));
left: 50%;
transform: translateX(-50%) scale(0.95);
}
.tooltip.top.visible {
transform: translateX(-50%) scale(1);
}
.tooltip.right {
left: calc(100% + var(--offset, 8px));
top: 50%;
transform: translateY(-50%) scale(0.95);
}
.tooltip.right.visible {
transform: translateY(-50%) scale(1);
}
.tooltip.bottom {
top: calc(100% + var(--offset, 8px));
left: 50%;
transform: translateX(-50%) scale(0.95);
}
.tooltip.bottom.visible {
transform: translateX(-50%) scale(1);
}
.tooltip.left {
right: calc(100% + var(--offset, 8px));
top: 50%;
transform: translateY(-50%) scale(0.95);
}
.tooltip.left.visible {
transform: translateY(-50%) scale(1);
}
/* Arrow */
.tooltip::after {
content: "";
position: absolute;
width: 8px;
height: 8px;
background: inherit;
transform: rotate(45deg);
}
.tooltip.top::after {
bottom: -4px;
left: 50%;
margin-left: -4px;
}
.tooltip.right::after {
left: -4px;
top: 50%;
margin-top: -4px;
}
.tooltip.bottom::after {
top: -4px;
left: 50%;
margin-left: -4px;
}
.tooltip.left::after {
right: -4px;
top: 50%;
margin-top: -4px;
}
.tooltip.no-arrow::after {
display: none;
}
@media (max-width: 640px) {
.tooltip {
max-width: 200px;
font-size: 0.75rem;
padding: 0.4rem 0.6rem;
}
}
`;
override render() {
return html`
<div
class="tooltip-container"
@mouseenter=${!this.disabled ? this._onMouseEnter : null}
@mouseleave=${!this.disabled ? this._onMouseLeave : null}
@focus=${!this.disabled ? this._onMouseEnter : null}
@blur=${!this.disabled ? this._onMouseLeave : null}
>
<slot></slot>
<div
class="tooltip ${this.position} ${this.variant} ${
this.isVisible ? 'visible' : ''
} ${!this.arrow ? 'no-arrow' : ''} ${this.interactive ? 'interactive' : ''}"
style="--offset: ${this.offset}px;"
role="tooltip"
aria-hidden=${!this.isVisible}
>
${this.allowHtml ? html`<div .innerHTML=${this.content}></div>` : this.content}
</div>
</div>
`;
}
private _onMouseEnter() {
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.isVisible = true;
}, this.delay);
}
private _onMouseLeave() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.isVisible = false;
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}