@arclux/arc-ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/package.json +186 -0
  4. package/src/content/accordion-item.js +27 -0
  5. package/src/content/accordion.js +151 -0
  6. package/src/content/animated-number.js +160 -0
  7. package/src/content/aspect-ratio.js +78 -0
  8. package/src/content/avatar-group.js +101 -0
  9. package/src/content/avatar.js +92 -0
  10. package/src/content/badge.js +98 -0
  11. package/src/content/callout.js +141 -0
  12. package/src/content/card.js +75 -0
  13. package/src/content/carousel.js +300 -0
  14. package/src/content/code-block.js +152 -0
  15. package/src/content/collapsible.js +142 -0
  16. package/src/content/color-swatch.js +86 -0
  17. package/src/content/column.js +28 -0
  18. package/src/content/data-table.js +332 -0
  19. package/src/content/divider.js +153 -0
  20. package/src/content/empty-state.js +78 -0
  21. package/src/content/feature-card.js +142 -0
  22. package/src/content/highlight.js +63 -0
  23. package/src/content/icon-library.js +30 -0
  24. package/src/content/icon-registry.js +39 -0
  25. package/src/content/icon.js +95 -0
  26. package/src/content/index.js +44 -0
  27. package/src/content/infinite-scroll.js +144 -0
  28. package/src/content/kbd.js +40 -0
  29. package/src/content/markdown.js +294 -0
  30. package/src/content/marquee.js +166 -0
  31. package/src/content/meter.js +167 -0
  32. package/src/content/scroll-area.js +107 -0
  33. package/src/content/skeleton.js +85 -0
  34. package/src/content/spinner.js +77 -0
  35. package/src/content/stack.js +68 -0
  36. package/src/content/stat.js +72 -0
  37. package/src/content/step.js +22 -0
  38. package/src/content/stepper.js +202 -0
  39. package/src/content/table.js +134 -0
  40. package/src/content/tag.js +156 -0
  41. package/src/content/text.js +111 -0
  42. package/src/content/timeline-item.js +29 -0
  43. package/src/content/timeline.js +170 -0
  44. package/src/content/truncate.js +161 -0
  45. package/src/content/value-card.js +94 -0
  46. package/src/feedback/alert.js +187 -0
  47. package/src/feedback/command-item.js +28 -0
  48. package/src/feedback/command-palette.js +346 -0
  49. package/src/feedback/context-menu.js +299 -0
  50. package/src/feedback/dialog.js +298 -0
  51. package/src/feedback/dropdown-menu.js +259 -0
  52. package/src/feedback/hover-card.js +186 -0
  53. package/src/feedback/index.js +17 -0
  54. package/src/feedback/modal.js +226 -0
  55. package/src/feedback/notification-panel.js +196 -0
  56. package/src/feedback/popover.js +184 -0
  57. package/src/feedback/progress.js +169 -0
  58. package/src/feedback/sheet.js +249 -0
  59. package/src/feedback/toast.js +207 -0
  60. package/src/feedback/tooltip.js +189 -0
  61. package/src/icons/lucide.d.ts +1915 -0
  62. package/src/icons/lucide.js +1915 -0
  63. package/src/icons/phosphor.d.ts +1517 -0
  64. package/src/icons/phosphor.js +1517 -0
  65. package/src/icons/types.d.ts +8 -0
  66. package/src/index.js +9 -0
  67. package/src/input/button.js +127 -0
  68. package/src/input/calendar.js +340 -0
  69. package/src/input/checkbox.js +159 -0
  70. package/src/input/chip.js +120 -0
  71. package/src/input/color-picker.js +461 -0
  72. package/src/input/combobox.js +295 -0
  73. package/src/input/copy-button.js +144 -0
  74. package/src/input/date-picker.js +534 -0
  75. package/src/input/file-upload.js +333 -0
  76. package/src/input/form.js +179 -0
  77. package/src/input/icon-button.js +179 -0
  78. package/src/input/index.js +31 -0
  79. package/src/input/input.js +158 -0
  80. package/src/input/multi-select.js +392 -0
  81. package/src/input/number-input.js +239 -0
  82. package/src/input/otp-input.js +221 -0
  83. package/src/input/pin-input.js +294 -0
  84. package/src/input/radio-group.js +177 -0
  85. package/src/input/radio.js +28 -0
  86. package/src/input/rating.js +195 -0
  87. package/src/input/search.js +371 -0
  88. package/src/input/segmented-control.js +174 -0
  89. package/src/input/select.js +267 -0
  90. package/src/input/slider.js +217 -0
  91. package/src/input/sortable-list.js +345 -0
  92. package/src/input/suggestion.js +26 -0
  93. package/src/input/textarea.js +203 -0
  94. package/src/input/theme-toggle.js +196 -0
  95. package/src/input/toggle.js +166 -0
  96. package/src/layout/app-shell.js +266 -0
  97. package/src/layout/auth-shell.js +153 -0
  98. package/src/layout/container.js +37 -0
  99. package/src/layout/dashboard-grid.js +62 -0
  100. package/src/layout/index.js +15 -0
  101. package/src/layout/page-header.js +100 -0
  102. package/src/layout/page-layout.js +112 -0
  103. package/src/layout/resizable.js +221 -0
  104. package/src/layout/section.js +54 -0
  105. package/src/layout/settings-layout.js +91 -0
  106. package/src/layout/split-pane.js +172 -0
  107. package/src/layout/status-bar.js +84 -0
  108. package/src/layout/toolbar.js +92 -0
  109. package/src/navigation/breadcrumb-item.js +26 -0
  110. package/src/navigation/breadcrumb.js +129 -0
  111. package/src/navigation/drawer.js +183 -0
  112. package/src/navigation/footer.js +99 -0
  113. package/src/navigation/index.js +22 -0
  114. package/src/navigation/link.js +120 -0
  115. package/src/navigation/nav-item.js +46 -0
  116. package/src/navigation/navigation-menu.js +687 -0
  117. package/src/navigation/pagination.js +186 -0
  118. package/src/navigation/scroll-spy.js +198 -0
  119. package/src/navigation/scroll-to-top.js +163 -0
  120. package/src/navigation/sidebar-link.js +28 -0
  121. package/src/navigation/sidebar-section.js +45 -0
  122. package/src/navigation/sidebar.js +336 -0
  123. package/src/navigation/spy-link.js +26 -0
  124. package/src/navigation/tab.js +26 -0
  125. package/src/navigation/tabs.js +202 -0
  126. package/src/navigation/top-bar.js +263 -0
  127. package/src/navigation/tree-item.js +38 -0
  128. package/src/navigation/tree-view.js +255 -0
  129. package/src/shared/index.js +6 -0
  130. package/src/shared/menu-divider.js +9 -0
  131. package/src/shared/menu-item.js +30 -0
  132. package/src/shared/option.js +31 -0
  133. package/src/shared-styles.js +81 -0
  134. package/src/tokens.js +445 -0
  135. package/types/index.d.ts +973 -0
@@ -0,0 +1,294 @@
1
+ import { LitElement, html, css, nothing } from 'lit';
2
+ import { tokenStyles } from '../shared-styles.js';
3
+
4
+ export class ArcPinInput extends LitElement {
5
+ static properties = {
6
+ length: { type: Number },
7
+ value: { type: String, reflect: true },
8
+ disabled: { type: Boolean, reflect: true },
9
+ mask: { type: Boolean, reflect: true },
10
+ type: { type: String, reflect: true },
11
+ separator: { type: Number },
12
+ label: { type: String },
13
+ };
14
+
15
+ static styles = [
16
+ tokenStyles,
17
+ css`
18
+ :host { display: inline-block; }
19
+ :host([disabled]) { pointer-events: none; opacity: 0.4; }
20
+
21
+ .pin {
22
+ display: flex;
23
+ flex-direction: column;
24
+ gap: var(--space-xs);
25
+ }
26
+
27
+ .pin__label {
28
+ font-family: var(--font-accent);
29
+ font-weight: 600;
30
+ font-size: var(--label-inline-size);
31
+ letter-spacing: var(--label-inline-spacing);
32
+ text-transform: uppercase;
33
+ color: var(--text-muted);
34
+ }
35
+
36
+ .pin__boxes {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: var(--space-sm);
40
+ }
41
+
42
+ .pin__separator {
43
+ font-family: var(--font-mono);
44
+ font-size: var(--text-lg);
45
+ color: var(--text-ghost);
46
+ user-select: none;
47
+ line-height: 1;
48
+ padding: 0 2px;
49
+ }
50
+
51
+ .pin__box {
52
+ width: 42px;
53
+ height: 48px;
54
+ text-align: center;
55
+ font-family: var(--font-mono);
56
+ font-size: var(--text-lg);
57
+ font-weight: 600;
58
+ color: var(--text-primary);
59
+ background: var(--bg-card);
60
+ border: 1px solid var(--border-default);
61
+ border-radius: var(--radius-md);
62
+ padding: 0;
63
+ caret-color: var(--accent-primary);
64
+ transition:
65
+ border-color var(--transition-fast),
66
+ box-shadow var(--transition-fast),
67
+ background var(--transition-fast);
68
+ box-sizing: border-box;
69
+ -moz-appearance: textfield;
70
+ }
71
+
72
+ .pin__box::-webkit-outer-spin-button,
73
+ .pin__box::-webkit-inner-spin-button {
74
+ -webkit-appearance: none;
75
+ margin: 0;
76
+ }
77
+
78
+ .pin__box::placeholder {
79
+ color: var(--text-ghost);
80
+ }
81
+
82
+ .pin__box:hover:not(:focus) {
83
+ border-color: var(--border-bright);
84
+ }
85
+
86
+ .pin__box:focus {
87
+ outline: none;
88
+ border-color: rgba(var(--accent-primary-rgb), 0.4);
89
+ box-shadow: var(--focus-glow);
90
+ background: var(--bg-surface);
91
+ }
92
+
93
+ .pin__box:focus-visible {
94
+ outline: none;
95
+ border-color: rgba(var(--accent-primary-rgb), 0.4);
96
+ box-shadow: var(--focus-glow);
97
+ }
98
+
99
+ .pin__box:disabled {
100
+ opacity: 0.4;
101
+ cursor: not-allowed;
102
+ }
103
+
104
+ :host([mask]) .pin__box {
105
+ -webkit-text-security: disc;
106
+ }
107
+
108
+ @media (prefers-reduced-motion: reduce) {
109
+ :host *,
110
+ :host *::before,
111
+ :host *::after {
112
+ animation-duration: 0.01ms !important;
113
+ animation-iteration-count: 1 !important;
114
+ transition-duration: 0.01ms !important;
115
+ }
116
+ }
117
+ `,
118
+ ];
119
+
120
+ constructor() {
121
+ super();
122
+ this.length = 4;
123
+ this.value = '';
124
+ this.disabled = false;
125
+ this.mask = false;
126
+ this.type = 'number';
127
+ this.separator = 0;
128
+ this.label = '';
129
+ }
130
+
131
+ get _chars() {
132
+ const chars = (this.value || '').split('');
133
+ return Array.from({ length: this.length }, (_, i) => chars[i] || '');
134
+ }
135
+
136
+ _getInputMode() {
137
+ if (this.type === 'number') return 'numeric';
138
+ return 'text';
139
+ }
140
+
141
+ _getPattern() {
142
+ if (this.type === 'number') return '[0-9]';
143
+ if (this.type === 'alphanumeric') return '[a-zA-Z0-9]';
144
+ return null;
145
+ }
146
+
147
+ _isValidChar(ch) {
148
+ if (this.type === 'number') return /^[0-9]$/.test(ch);
149
+ if (this.type === 'alphanumeric') return /^[a-zA-Z0-9]$/.test(ch);
150
+ return ch.length === 1;
151
+ }
152
+
153
+ _getBoxes() {
154
+ return Array.from(this.shadowRoot.querySelectorAll('.pin__box'));
155
+ }
156
+
157
+ _focusBox(index) {
158
+ const boxes = this._getBoxes();
159
+ if (boxes[index]) boxes[index].focus();
160
+ }
161
+
162
+ _buildValue() {
163
+ const boxes = this._getBoxes();
164
+ return boxes.map(b => b.value).join('');
165
+ }
166
+
167
+ _emitChange() {
168
+ const val = this._buildValue();
169
+ this.value = val;
170
+ this.dispatchEvent(new CustomEvent('arc-change', {
171
+ detail: { value: val },
172
+ bubbles: true,
173
+ composed: true,
174
+ }));
175
+
176
+ if (val.length === this.length) {
177
+ this.dispatchEvent(new CustomEvent('arc-complete', {
178
+ detail: { value: val },
179
+ bubbles: true,
180
+ composed: true,
181
+ }));
182
+ }
183
+ }
184
+
185
+ _onInput(e, index) {
186
+ const input = e.target;
187
+ let ch = input.value;
188
+
189
+ // Only take the last character if multiple were typed
190
+ if (ch.length > 1) ch = ch.slice(-1);
191
+
192
+ if (ch && !this._isValidChar(ch)) {
193
+ input.value = this._chars[index] || '';
194
+ return;
195
+ }
196
+
197
+ input.value = ch;
198
+ this._emitChange();
199
+
200
+ // Auto-advance
201
+ if (ch && index < this.length - 1) {
202
+ this._focusBox(index + 1);
203
+ }
204
+ }
205
+
206
+ _onKeydown(e, index) {
207
+ if (e.key === 'Backspace') {
208
+ const input = e.target;
209
+ if (!input.value && index > 0) {
210
+ e.preventDefault();
211
+ const boxes = this._getBoxes();
212
+ boxes[index - 1].value = '';
213
+ this._focusBox(index - 1);
214
+ this._emitChange();
215
+ }
216
+ } else if (e.key === 'ArrowLeft' && index > 0) {
217
+ e.preventDefault();
218
+ this._focusBox(index - 1);
219
+ } else if (e.key === 'ArrowRight' && index < this.length - 1) {
220
+ e.preventDefault();
221
+ this._focusBox(index + 1);
222
+ } else if (e.key === 'Delete') {
223
+ e.target.value = '';
224
+ this._emitChange();
225
+ }
226
+ }
227
+
228
+ _onPaste(e, index) {
229
+ e.preventDefault();
230
+ const pasted = (e.clipboardData || window.clipboardData).getData('text').trim();
231
+ if (!pasted) return;
232
+
233
+ const boxes = this._getBoxes();
234
+ let pos = index;
235
+ for (const ch of pasted) {
236
+ if (pos >= this.length) break;
237
+ if (this._isValidChar(ch)) {
238
+ boxes[pos].value = ch;
239
+ pos++;
240
+ }
241
+ }
242
+
243
+ this._emitChange();
244
+
245
+ // Focus last filled or next empty box
246
+ const focusIdx = Math.min(pos, this.length - 1);
247
+ this._focusBox(focusIdx);
248
+ }
249
+
250
+ _onFocus(e) {
251
+ // Select content on focus for easy replacement
252
+ e.target.select();
253
+ }
254
+
255
+ _needsSeparator(index) {
256
+ if (!this.separator || this.separator <= 0) return false;
257
+ return (index + 1) < this.length && (index + 1) % this.separator === 0;
258
+ }
259
+
260
+ render() {
261
+ const chars = this._chars;
262
+ const pattern = this._getPattern();
263
+ const inputMode = this._getInputMode();
264
+
265
+ return html`
266
+ <div class="pin" part="pin">
267
+ ${this.label ? html`<span class="pin__label" part="label">${this.label}</span>` : ''}
268
+ <div class="pin__boxes" role="group" aria-label=${this.label || 'PIN input'} part="boxes">
269
+ ${chars.map((ch, i) => html`
270
+ <input
271
+ class="pin__box"
272
+ type=${this.mask ? 'password' : 'text'}
273
+ inputmode=${inputMode}
274
+ maxlength="1"
275
+ autocomplete="one-time-code"
276
+ .value=${ch}
277
+ ?disabled=${this.disabled}
278
+ aria-label=${`${this.label || 'PIN'} digit ${i + 1}`}
279
+ ${pattern ? html`pattern=${pattern}` : nothing}
280
+ @input=${(e) => this._onInput(e, i)}
281
+ @keydown=${(e) => this._onKeydown(e, i)}
282
+ @paste=${(e) => this._onPaste(e, i)}
283
+ @focus=${this._onFocus}
284
+ part="box"
285
+ />
286
+ ${this._needsSeparator(i) ? html`<span class="pin__separator" aria-hidden="true">&ndash;</span>` : ''}
287
+ `)}
288
+ </div>
289
+ </div>
290
+ `;
291
+ }
292
+ }
293
+
294
+ customElements.define('arc-pin-input', ArcPinInput);
@@ -0,0 +1,177 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { tokenStyles } from '../shared-styles.js';
3
+ import './radio.js';
4
+
5
+ export class ArcRadioGroup extends LitElement {
6
+ static formAssociated = true;
7
+
8
+ static properties = {
9
+ value: { type: String, reflect: true },
10
+ name: { type: String },
11
+ disabled: { type: Boolean, reflect: true },
12
+ orientation: { type: String, reflect: true },
13
+ _radios: { state: true },
14
+ };
15
+
16
+ static styles = [
17
+ tokenStyles,
18
+ css`
19
+ :host { display: block; }
20
+ :host([disabled]) { pointer-events: none; opacity: 0.4; }
21
+
22
+ .radio-group {
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: var(--space-sm);
26
+ }
27
+
28
+ :host([orientation="horizontal"]) .radio-group {
29
+ flex-direction: row;
30
+ gap: var(--space-lg);
31
+ }
32
+
33
+ .radio {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ gap: var(--space-sm);
37
+ cursor: pointer;
38
+ min-height: var(--touch-min);
39
+ }
40
+
41
+ .radio__circle {
42
+ position: relative;
43
+ width: 18px;
44
+ height: 18px;
45
+ border-radius: var(--radius-full);
46
+ border: 1px solid var(--border-bright);
47
+ background: var(--bg-surface);
48
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
49
+ flex-shrink: 0;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ }
54
+
55
+ .radio__dot {
56
+ width: 8px;
57
+ height: 8px;
58
+ border-radius: var(--radius-full);
59
+ background: var(--accent-primary);
60
+ opacity: 0;
61
+ transform: scale(0);
62
+ transition: opacity var(--transition-fast), transform var(--transition-fast);
63
+ }
64
+
65
+ .radio[aria-checked="true"] .radio__circle {
66
+ border-color: var(--accent-primary);
67
+ box-shadow: 0 0 8px rgba(var(--accent-primary-rgb), 0.3);
68
+ }
69
+
70
+ .radio[aria-checked="true"] .radio__dot {
71
+ opacity: 1;
72
+ transform: scale(1);
73
+ }
74
+
75
+ .radio__label {
76
+ font-family: var(--font-body);
77
+ font-size: var(--body-size);
78
+ color: var(--text-secondary);
79
+ user-select: none;
80
+ }
81
+
82
+ .radio__circle:focus-visible {
83
+ outline: none;
84
+ box-shadow: var(--focus-glow);
85
+ }
86
+
87
+ .radio-group__slot-host { display: none; }
88
+
89
+ @media (prefers-reduced-motion: reduce) {
90
+ :host *,
91
+ :host *::before,
92
+ :host *::after {
93
+ animation-duration: 0.01ms !important;
94
+ animation-iteration-count: 1 !important;
95
+ transition-duration: 0.01ms !important;
96
+ }
97
+ }
98
+ `,
99
+ ];
100
+
101
+ constructor() {
102
+ super();
103
+ this._internals = this.attachInternals();
104
+ this.value = '';
105
+ this.name = '';
106
+ this.disabled = false;
107
+ this.orientation = 'vertical';
108
+ this._radios = [];
109
+ }
110
+
111
+ updated(changed) {
112
+ if (changed.has('value')) {
113
+ this._internals.setFormValue(this.value);
114
+ }
115
+ }
116
+
117
+ _onSlotChange(e) {
118
+ this._radios = e.target.assignedElements({ flatten: true })
119
+ .filter(el => el.tagName === 'ARC-RADIO');
120
+ }
121
+
122
+ _select(val) {
123
+ if (this.disabled) return;
124
+ this.value = val;
125
+ this.dispatchEvent(new CustomEvent('arc-change', {
126
+ detail: { value: this.value },
127
+ bubbles: true,
128
+ composed: true,
129
+ }));
130
+ }
131
+
132
+ _handleKeydown(e, index) {
133
+ const opts = this._radios;
134
+ let next;
135
+
136
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = (index + 1) % opts.length;
137
+ else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = (index - 1 + opts.length) % opts.length;
138
+ else return;
139
+
140
+ e.preventDefault();
141
+ this._select(opts[next].value);
142
+ this.updateComplete.then(() => {
143
+ const radios = this.shadowRoot.querySelectorAll('.radio__circle');
144
+ radios[next]?.focus();
145
+ });
146
+ }
147
+
148
+ render() {
149
+ return html`
150
+ <div class="radio-group__slot-host">
151
+ <slot @slotchange=${this._onSlotChange}></slot>
152
+ </div>
153
+ <div class="radio-group" role="radiogroup" aria-label=${this.name} part="group">
154
+ ${this._radios.map((opt, i) => html`
155
+ <div
156
+ class="radio"
157
+ role="radio"
158
+ aria-checked=${opt.value === this.value ? 'true' : 'false'}
159
+ @click=${() => this._select(opt.value)}
160
+ >
161
+ <div
162
+ class="radio__circle"
163
+ tabindex=${opt.value === this.value || (!this.value && i === 0) ? '0' : '-1'}
164
+ @keydown=${(e) => this._handleKeydown(e, i)}
165
+ part="circle"
166
+ >
167
+ <div class="radio__dot"></div>
168
+ </div>
169
+ <span class="radio__label" part="label">${opt.label}</span>
170
+ </div>
171
+ `)}
172
+ </div>
173
+ `;
174
+ }
175
+ }
176
+
177
+ customElements.define('arc-radio-group', ArcRadioGroup);
@@ -0,0 +1,28 @@
1
+ import { LitElement, html, css } from 'lit';
2
+
3
+ export class ArcRadio extends LitElement {
4
+ static properties = {
5
+ value: { type: String, reflect: true },
6
+ disabled: { type: Boolean, reflect: true },
7
+ };
8
+
9
+ static styles = css`
10
+ :host { display: none; }
11
+ `;
12
+
13
+ constructor() {
14
+ super();
15
+ this.value = '';
16
+ this.disabled = false;
17
+ }
18
+
19
+ get label() {
20
+ return this.textContent.trim();
21
+ }
22
+
23
+ render() {
24
+ return html`<slot></slot>`;
25
+ }
26
+ }
27
+
28
+ customElements.define('arc-radio', ArcRadio);
@@ -0,0 +1,195 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { tokenStyles } from '../shared-styles.js';
3
+
4
+ export class ArcRating extends LitElement {
5
+ static properties = {
6
+ value: { type: Number, reflect: true },
7
+ max: { type: Number, reflect: true },
8
+ disabled: { type: Boolean, reflect: true },
9
+ readonly: { type: Boolean, reflect: true },
10
+ };
11
+
12
+ static styles = [
13
+ tokenStyles,
14
+ css`
15
+ :host { display: inline-flex; }
16
+ :host([disabled]) { pointer-events: none; opacity: 0.4; }
17
+ :host([readonly]) { pointer-events: none; }
18
+
19
+ .rating {
20
+ display: inline-flex;
21
+ align-items: center;
22
+ gap: 2px;
23
+ outline: none;
24
+ border-radius: var(--radius-sm);
25
+ padding: var(--space-xs);
26
+ }
27
+
28
+ .rating:focus-visible {
29
+ box-shadow: var(--focus-glow);
30
+ }
31
+
32
+ .rating__star {
33
+ display: inline-flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ cursor: pointer;
37
+ padding: 0;
38
+ background: none;
39
+ border: none;
40
+ color: var(--border-default);
41
+ transition:
42
+ color var(--transition-fast),
43
+ transform var(--transition-fast),
44
+ filter var(--transition-fast);
45
+ flex-shrink: 0;
46
+ position: relative;
47
+ }
48
+
49
+ .rating__star--filled {
50
+ color: var(--accent-primary);
51
+ filter: drop-shadow(0 0 4px rgba(var(--accent-primary-rgb), 0.4))
52
+ drop-shadow(0 0 10px rgba(var(--accent-primary-rgb), 0.2));
53
+ }
54
+
55
+ .rating__star--hovered {
56
+ color: var(--accent-primary);
57
+ filter: drop-shadow(0 0 6px rgba(var(--accent-primary-rgb), 0.5))
58
+ drop-shadow(0 0 16px rgba(var(--accent-primary-rgb), 0.3));
59
+ }
60
+
61
+ .rating__star:not(.rating__star--filled):not(.rating__star--hovered):hover {
62
+ color: var(--border-bright);
63
+ }
64
+
65
+ .rating__star svg {
66
+ width: 28px;
67
+ height: 28px;
68
+ pointer-events: none;
69
+ }
70
+
71
+ @media (prefers-reduced-motion: reduce) {
72
+ .rating__star { transition: none; }
73
+ }
74
+ `,
75
+ ];
76
+
77
+ constructor() {
78
+ super();
79
+ this.value = 0;
80
+ this.max = 5;
81
+ this.disabled = false;
82
+ this.readonly = false;
83
+ this._hoverValue = 0;
84
+ }
85
+
86
+ _onStarClick(index) {
87
+ if (this.disabled || this.readonly) return;
88
+ this.value = index;
89
+ this.dispatchEvent(new CustomEvent('arc-change', {
90
+ detail: { value: this.value },
91
+ bubbles: true,
92
+ composed: true,
93
+ }));
94
+ }
95
+
96
+ _onStarEnter(index) {
97
+ if (this.disabled || this.readonly) return;
98
+ this._hoverValue = index;
99
+ this.requestUpdate();
100
+ }
101
+
102
+ _onStarLeave() {
103
+ if (this.disabled || this.readonly) return;
104
+ this._hoverValue = 0;
105
+ this.requestUpdate();
106
+ }
107
+
108
+ _onKeydown(e) {
109
+ if (this.disabled || this.readonly) return;
110
+
111
+ let newValue = this.value;
112
+ switch (e.key) {
113
+ case 'ArrowRight':
114
+ case 'ArrowUp':
115
+ e.preventDefault();
116
+ newValue = Math.min(this.value + 1, this.max);
117
+ break;
118
+ case 'ArrowLeft':
119
+ case 'ArrowDown':
120
+ e.preventDefault();
121
+ newValue = Math.max(this.value - 1, 1);
122
+ break;
123
+ case 'Home':
124
+ e.preventDefault();
125
+ newValue = 1;
126
+ break;
127
+ case 'End':
128
+ e.preventDefault();
129
+ newValue = this.max;
130
+ break;
131
+ default:
132
+ return;
133
+ }
134
+
135
+ if (newValue !== this.value) {
136
+ this.value = newValue;
137
+ this.dispatchEvent(new CustomEvent('arc-change', {
138
+ detail: { value: this.value },
139
+ bubbles: true,
140
+ composed: true,
141
+ }));
142
+ }
143
+ }
144
+
145
+ _renderStar(index) {
146
+ const displayValue = this._hoverValue || this.value;
147
+ const filled = index <= displayValue;
148
+ const hovered = this._hoverValue > 0 && index <= this._hoverValue;
149
+
150
+ const starPath = filled
151
+ ? 'M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 21 12 17.27 5.82 21 7 14.14l-5-4.87 6.91-1.01L12 2z'
152
+ : 'M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 21 12 17.27 5.82 21 7 14.14l-5-4.87 6.91-1.01L12 2z';
153
+
154
+ return html`
155
+ <span
156
+ class="rating__star ${filled ? 'rating__star--filled' : ''} ${hovered ? 'rating__star--hovered' : ''}"
157
+ @click=${() => this._onStarClick(index)}
158
+ @mouseenter=${() => this._onStarEnter(index)}
159
+ @mouseleave=${this._onStarLeave}
160
+ part="star"
161
+ >
162
+ <svg viewBox="0 0 24 24" fill=${filled ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" aria-hidden="true">
163
+ <path d=${starPath} />
164
+ </svg>
165
+ </span>
166
+ `;
167
+ }
168
+
169
+ render() {
170
+ const stars = [];
171
+ for (let i = 1; i <= this.max; i++) {
172
+ stars.push(this._renderStar(i));
173
+ }
174
+
175
+ return html`
176
+ <div
177
+ class="rating"
178
+ role="slider"
179
+ aria-label="Rating"
180
+ aria-valuemin="1"
181
+ aria-valuemax=${this.max}
182
+ aria-valuenow=${this.value}
183
+ aria-disabled=${this.disabled ? 'true' : 'false'}
184
+ aria-readonly=${this.readonly ? 'true' : 'false'}
185
+ tabindex=${this.disabled ? '-1' : '0'}
186
+ @keydown=${this._onKeydown}
187
+ part="rating"
188
+ >
189
+ ${stars}
190
+ </div>
191
+ `;
192
+ }
193
+ }
194
+
195
+ customElements.define('arc-rating', ArcRating);