affora 0.1.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.
@@ -0,0 +1,279 @@
1
+ import type React from 'react';
2
+ import { useId, useState } from 'react';
3
+
4
+ /**
5
+ * Combobox — a searchable select for choosing a JavaScript framework.
6
+ *
7
+ * Substrate is deliberately plain and agent-legible:
8
+ * <label> + real <input list> + <datalist> of real <option>s.
9
+ * The options live in the DOM, the chosen value is the input's visible value,
10
+ * and an agent can simply type a value or read the listed options. Every
11
+ * stylistic decision is a var(--token) so the ONE authored component expresses
12
+ * all five theme archetypes without baking in a single look.
13
+ */
14
+
15
+ type Framework = {
16
+ value: string;
17
+ hint: string; // short editorial descriptor shown as inline documentation
18
+ };
19
+
20
+ const FRAMEWORKS: Framework[] = [
21
+ { value: 'React', hint: 'Component UI · virtual DOM' },
22
+ { value: 'Vue', hint: 'Progressive · template-first' },
23
+ { value: 'Svelte', hint: 'Compiler · no runtime' },
24
+ { value: 'Angular', hint: 'Batteries-included platform' },
25
+ { value: 'Solid', hint: 'Fine-grained reactivity' },
26
+ { value: 'Qwik', hint: 'Resumable · zero-hydration' },
27
+ ];
28
+
29
+ const VALID = new Set(FRAMEWORKS.map((f) => f.value.toLowerCase()));
30
+
31
+ export const Combobox: React.FC<{ onCommit?: (v: string) => void; initialValue?: string }> = ({ onCommit, initialValue = '' }) => {
32
+ const inputId = useId();
33
+ const listId = useId();
34
+ const statusId = useId();
35
+ const [value, setValue] = useState(initialValue);
36
+
37
+ const trimmed = value.trim();
38
+ const matched = trimmed !== '' && VALID.has(trimmed.toLowerCase());
39
+
40
+ const commit = (raw: string) => {
41
+ const v = raw.trim();
42
+ setValue(v);
43
+ if (v !== '') onCommit?.(v);
44
+ };
45
+
46
+ return (
47
+ <div className="cbx-scope">
48
+ <style>{css}</style>
49
+
50
+ <div className="cbx-field">
51
+ <label className="cbx-label" htmlFor={inputId}>
52
+ Framework
53
+ </label>
54
+ <p className="cbx-help" id={`${inputId}-help`}>
55
+ Type or pick the JavaScript framework for this project.
56
+ </p>
57
+
58
+ <div className="cbx-control" data-matched={matched ? 'true' : 'false'}>
59
+ <FrameworkGlyph />
60
+ <input
61
+ id={inputId}
62
+ className="cbx-input"
63
+ type="text"
64
+ list={listId}
65
+ role="combobox"
66
+ aria-expanded="true"
67
+ aria-controls={listId}
68
+ aria-describedby={`${inputId}-help ${statusId}`}
69
+ autoComplete="off"
70
+ spellCheck={false}
71
+ placeholder="Search frameworks…"
72
+ value={value}
73
+ onChange={(e) => setValue(e.target.value)}
74
+ onInput={(e) => {
75
+ // datalist selection fires input with the full option value
76
+ const v = (e.target as HTMLInputElement).value;
77
+ if (VALID.has(v.trim().toLowerCase())) commit(v);
78
+ }}
79
+ onBlur={(e) => {
80
+ if (e.target.value.trim() !== '') commit(e.target.value);
81
+ }}
82
+ onKeyDown={(e) => {
83
+ if (e.key === 'Enter') {
84
+ e.preventDefault();
85
+ commit((e.target as HTMLInputElement).value);
86
+ }
87
+ }}
88
+ />
89
+ <ChevronGlyph />
90
+ </div>
91
+
92
+ <datalist id={listId}>
93
+ {FRAMEWORKS.map((f) => (
94
+ <option key={f.value} value={f.value}>
95
+ {f.hint}
96
+ </option>
97
+ ))}
98
+ </datalist>
99
+
100
+ <p className="cbx-status" id={statusId} role="status" aria-live="polite">
101
+ {matched ? (
102
+ <>
103
+ <CheckGlyph />
104
+ <span>
105
+ <strong className="cbx-status-value">{trimmed}</strong> selected
106
+ </span>
107
+ </>
108
+ ) : (
109
+ <span className="cbx-status-idle">
110
+ {FRAMEWORKS.length} frameworks available
111
+ </span>
112
+ )}
113
+ </p>
114
+ </div>
115
+ </div>
116
+ );
117
+ };
118
+
119
+ /* ---- inline SVG glyphs (currentColor only, no external assets) ---- */
120
+
121
+ const FrameworkGlyph: React.FC = () => (
122
+ <svg className="cbx-lead" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
123
+ <path
124
+ d="M12 3 3 7.5 12 12l9-4.5L12 3Z"
125
+ stroke="currentColor"
126
+ strokeWidth="1.6"
127
+ strokeLinejoin="round"
128
+ />
129
+ <path
130
+ d="M3 12l9 4.5L21 12M3 16.5 12 21l9-4.5"
131
+ stroke="currentColor"
132
+ strokeWidth="1.6"
133
+ strokeLinejoin="round"
134
+ opacity="0.55"
135
+ />
136
+ </svg>
137
+ );
138
+
139
+ const ChevronGlyph: React.FC = () => (
140
+ <svg className="cbx-chevron" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
141
+ <path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
142
+ </svg>
143
+ );
144
+
145
+ const CheckGlyph: React.FC = () => (
146
+ <svg className="cbx-check" width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
147
+ <path d="m5 12.5 4.2 4.5L19 7" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
148
+ </svg>
149
+ );
150
+
151
+ export const css = `
152
+ .cbx-scope {
153
+ color: var(--fg);
154
+ font-family: var(--font);
155
+ font-size: var(--text-base);
156
+ line-height: var(--leading);
157
+ -webkit-font-smoothing: antialiased;
158
+ }
159
+ .cbx-scope * { box-sizing: border-box; }
160
+
161
+ .cbx-field {
162
+ display: flex;
163
+ flex-direction: column;
164
+ gap: var(--gap);
165
+ width: 100%;
166
+ max-width: 380px;
167
+ padding: var(--pad-loose);
168
+ background: var(--glass-bg, var(--surface));
169
+ backdrop-filter: var(--glass-blur, none);
170
+ -webkit-backdrop-filter: var(--glass-blur, none);
171
+ border: 1px solid var(--glass-border, var(--border));
172
+ border-radius: var(--radius-lg);
173
+ box-shadow: var(--shadow-sm);
174
+ }
175
+
176
+ .cbx-label {
177
+ font-family: var(--font-display);
178
+ font-size: calc(var(--text-base) * 1.15);
179
+ font-weight: var(--weight-display);
180
+ letter-spacing: var(--tracking-tight);
181
+ line-height: 1.2;
182
+ }
183
+ .cbx-help {
184
+ margin: calc(var(--gap) * -0.5) 0 0;
185
+ color: var(--fg-muted);
186
+ font-size: calc(var(--text-base) * 0.92);
187
+ }
188
+
189
+ .cbx-control {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: var(--pad-tight);
193
+ padding: 0 var(--pad);
194
+ min-height: calc(var(--text-base) * 3.2);
195
+ background: var(--bg);
196
+ border: 1px solid var(--border-strong);
197
+ border-radius: var(--radius);
198
+ transition:
199
+ border-color var(--dur) var(--ease),
200
+ box-shadow var(--dur) var(--ease),
201
+ background var(--dur) var(--ease);
202
+ }
203
+ .cbx-control:hover {
204
+ border-color: var(--accent);
205
+ }
206
+ .cbx-control:focus-within {
207
+ border-color: var(--accent);
208
+ box-shadow: 0 0 0 3px var(--ring);
209
+ background: var(--surface);
210
+ }
211
+ .cbx-control[data-matched='true'] {
212
+ border-color: var(--accent);
213
+ background: var(--accent-weak);
214
+ }
215
+ .cbx-control[data-matched='true']:focus-within {
216
+ background: var(--surface);
217
+ }
218
+
219
+ .cbx-lead {
220
+ flex: none;
221
+ color: var(--accent);
222
+ }
223
+
224
+ .cbx-input {
225
+ flex: 1 1 auto;
226
+ min-width: 0;
227
+ appearance: none;
228
+ border: none;
229
+ outline: none;
230
+ background: transparent;
231
+ color: var(--fg);
232
+ font-family: var(--font);
233
+ font-size: var(--text-base);
234
+ font-weight: var(--weight-medium);
235
+ letter-spacing: var(--tracking-tight);
236
+ padding: var(--pad-tight) 0;
237
+ }
238
+ .cbx-input::placeholder {
239
+ color: var(--fg-subtle);
240
+ font-weight: var(--weight-normal);
241
+ }
242
+ /* keep the native datalist affordance from doubling our custom chevron */
243
+ .cbx-input::-webkit-calendar-picker-indicator {
244
+ opacity: 0;
245
+ width: 0;
246
+ margin: 0;
247
+ }
248
+
249
+ .cbx-chevron {
250
+ flex: none;
251
+ color: var(--fg-subtle);
252
+ transition: color var(--dur) var(--ease), transform var(--dur) var(--ease-emphasis);
253
+ }
254
+ .cbx-control:focus-within .cbx-chevron {
255
+ color: var(--accent);
256
+ transform: rotate(180deg);
257
+ }
258
+
259
+ .cbx-status {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: var(--pad-tight);
263
+ margin: 0;
264
+ min-height: calc(var(--text-base) * 1.3);
265
+ font-size: calc(var(--text-base) * 0.92);
266
+ color: var(--fg-muted);
267
+ }
268
+ .cbx-status-idle { color: var(--fg-subtle); }
269
+ .cbx-status-value {
270
+ color: var(--fg);
271
+ font-weight: var(--weight-bold);
272
+ }
273
+ .cbx-check {
274
+ flex: none;
275
+ color: var(--success);
276
+ }
277
+ `;
278
+
279
+ export default Combobox;