@cfasim-ui/components 0.7.7 → 0.8.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.
- package/dist/Box/Box.d.ts +7 -11
- package/dist/Button/Button.d.ts +7 -11
- package/dist/ButtonGroup/ButtonGroup.d.ts +8 -12
- package/dist/Container/Container.d.ts +7 -11
- package/dist/Expander/Expander.d.ts +12 -14
- package/dist/Grid/Grid.d.ts +6 -5
- package/dist/Hint/Hint.d.ts +2 -1
- package/dist/Icon/Icon.d.ts +3 -2
- package/dist/LightDarkToggle/LightDarkToggle.d.ts +2 -1
- package/dist/MultiSelect/MultiSelect.d.ts +6 -6
- package/dist/NumberInput/NumberInput.d.ts +13 -11
- package/dist/ParamEditor/ParamEditor.d.ts +3 -2
- package/dist/ParamEditor/ParamEditorImpl.d.ts +3 -5
- package/dist/{ParamEditorImpl-D0xZTyAN.js → ParamEditorImpl-CkElC6PI.js} +3075 -3041
- package/dist/SelectBox/SelectBox.d.ts +8 -6
- package/dist/{SelectBox-CByXZfaC.js → SelectBox-Df8dE2r-.js} +6 -6
- package/dist/SidebarLayout/SidebarLayout.d.ts +21 -20
- package/dist/Spinner/Spinner.d.ts +3 -2
- package/dist/TextInput/TextInput.d.ts +7 -5
- package/dist/Toggle/Toggle.d.ts +8 -6
- package/dist/ToggleGroup/ToggleGroup.d.ts +8 -6
- package/dist/_internal/FieldLabel.d.ts +2 -1
- package/dist/index.js +20 -20
- package/docs/Box.md +49 -0
- package/docs/Button.md +67 -0
- package/docs/ButtonGroup.md +64 -0
- package/docs/Container.md +103 -0
- package/docs/Expander.md +34 -0
- package/docs/Grid.md +170 -0
- package/docs/Hint.md +29 -0
- package/docs/Icon.md +188 -0
- package/docs/MultiSelect.md +143 -0
- package/docs/NumberInput.md +485 -0
- package/docs/ParamEditor.md +97 -0
- package/docs/SelectBox.md +182 -0
- package/docs/SidebarLayout.md +106 -0
- package/docs/Spinner.md +51 -0
- package/docs/TextInput.md +83 -0
- package/docs/Toggle.md +81 -0
- package/docs/ToggleGroup.md +163 -0
- package/docs/index.json +194 -0
- package/package.json +24 -20
- package/src/Box/Box.md +41 -0
- package/src/Box/Box.vue +52 -0
- package/src/Button/Button.md +59 -0
- package/src/Button/Button.vue +81 -0
- package/src/ButtonGroup/ButtonGroup.md +62 -0
- package/src/ButtonGroup/ButtonGroup.vue +91 -0
- package/src/Container/Container.md +99 -0
- package/src/Container/Container.vue +62 -0
- package/src/Expander/Expander.md +23 -0
- package/src/Expander/Expander.vue +95 -0
- package/src/Grid/Grid.md +175 -0
- package/src/Grid/Grid.vue +145 -0
- package/src/Hint/Hint.md +24 -0
- package/src/Hint/Hint.vue +83 -0
- package/src/Icon/Icon.md +176 -0
- package/src/Icon/Icon.vue +104 -0
- package/src/Icon/defaultIcons.ts +92 -0
- package/src/Icon/github.svg +1 -0
- package/src/Icon/registry.ts +68 -0
- package/src/LightDarkToggle/LightDarkToggle.vue +49 -0
- package/src/MultiSelect/MultiSelect.md +142 -0
- package/src/MultiSelect/MultiSelect.vue +279 -0
- package/src/NumberInput/NumberInput.md +455 -0
- package/src/NumberInput/NumberInput.vue +575 -0
- package/src/ParamEditor/ParamEditor.md +87 -0
- package/src/ParamEditor/ParamEditor.vue +43 -0
- package/src/ParamEditor/ParamEditorImpl.vue +358 -0
- package/src/SelectBox/SelectBox.md +169 -0
- package/src/SelectBox/SelectBox.vue +260 -0
- package/src/SidebarLayout/SidebarLayout.md +106 -0
- package/src/SidebarLayout/SidebarLayout.vue +468 -0
- package/src/Spinner/Spinner.md +45 -0
- package/src/Spinner/Spinner.vue +55 -0
- package/src/TextInput/TextInput.md +68 -0
- package/src/TextInput/TextInput.vue +54 -0
- package/src/Toggle/Toggle.md +68 -0
- package/src/Toggle/Toggle.vue +81 -0
- package/src/ToggleGroup/ToggleGroup.md +158 -0
- package/src/ToggleGroup/ToggleGroup.vue +138 -0
- package/src/_internal/FieldLabel.vue +27 -0
- package/src/_internal/field.ts +27 -0
- package/src/_internal/gap.ts +17 -0
- package/src/_internal/input.css +54 -0
- package/src/_internal/listbox.css +53 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +33 -0
- package/src/svg.d.ts +5 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
ref,
|
|
4
|
+
computed,
|
|
5
|
+
watch,
|
|
6
|
+
useTemplateRef,
|
|
7
|
+
onMounted,
|
|
8
|
+
onUnmounted,
|
|
9
|
+
} from "vue";
|
|
10
|
+
import { Codemirror } from "vue-codemirror";
|
|
11
|
+
import { json as jsonLang } from "@codemirror/lang-json";
|
|
12
|
+
import { yaml as yamlLang } from "@codemirror/lang-yaml";
|
|
13
|
+
import { StreamLanguage } from "@codemirror/language";
|
|
14
|
+
import { toml as tomlMode } from "@codemirror/legacy-modes/mode/toml";
|
|
15
|
+
import { EditorView, keymap } from "@codemirror/view";
|
|
16
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
17
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
18
|
+
import Box from "../Box/Box.vue";
|
|
19
|
+
import Button from "../Button/Button.vue";
|
|
20
|
+
import SelectBox from "../SelectBox/SelectBox.vue";
|
|
21
|
+
|
|
22
|
+
type Format = "json" | "toml" | "yaml";
|
|
23
|
+
type Value = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(
|
|
26
|
+
defineProps<{
|
|
27
|
+
value: Value;
|
|
28
|
+
format?: Format;
|
|
29
|
+
height?: string;
|
|
30
|
+
fontSize?: string;
|
|
31
|
+
filename?: string;
|
|
32
|
+
}>(),
|
|
33
|
+
{ format: "json", height: "320px", fontSize: "var(--font-size-sm)" },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const emit = defineEmits<{ apply: [value: Value] }>();
|
|
37
|
+
|
|
38
|
+
const formatOptions = [
|
|
39
|
+
{ value: "json", label: "JSON" },
|
|
40
|
+
{ value: "toml", label: "TOML" },
|
|
41
|
+
{ value: "yaml", label: "YAML" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function serialize(v: Value, f: Format): string {
|
|
45
|
+
if (f === "json") return JSON.stringify(v, null, 2);
|
|
46
|
+
if (f === "yaml") return stringifyYaml(v);
|
|
47
|
+
return stringifyToml(v);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parse(t: string, f: Format): Value {
|
|
51
|
+
if (f === "json") return JSON.parse(t) as Value;
|
|
52
|
+
if (f === "yaml") return parseYaml(t) as Value;
|
|
53
|
+
return parseToml(t) as Value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const format = ref<Format>(props.format);
|
|
57
|
+
const text = ref(serialize(props.value, format.value));
|
|
58
|
+
const error = ref("");
|
|
59
|
+
|
|
60
|
+
// Clear a previously-surfaced parse error as soon as the text parses
|
|
61
|
+
// cleanly again, so the user doesn't have to re-trigger Apply just to
|
|
62
|
+
// see the error go away after they fix it.
|
|
63
|
+
watch(text, () => {
|
|
64
|
+
if (!error.value) return;
|
|
65
|
+
try {
|
|
66
|
+
parse(text.value, format.value);
|
|
67
|
+
error.value = "";
|
|
68
|
+
} catch {
|
|
69
|
+
// Still broken — keep the error visible.
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const isMac =
|
|
74
|
+
typeof navigator !== "undefined" &&
|
|
75
|
+
/Mac|iPhone|iPad/.test(navigator.platform);
|
|
76
|
+
const applyShortcut = isMac ? "⌘S" : "Ctrl+S";
|
|
77
|
+
|
|
78
|
+
// External value updates (e.g., parent Reset) re-seed the editor. We
|
|
79
|
+
// compare against the last value we sourced from the parent — not
|
|
80
|
+
// against the live editor text — so a parent re-render that hands us a
|
|
81
|
+
// fresh object ref with the same contents doesn't clobber whatever the
|
|
82
|
+
// user is currently typing.
|
|
83
|
+
let lastSeeded = text.value;
|
|
84
|
+
watch(
|
|
85
|
+
() => props.value,
|
|
86
|
+
(v) => {
|
|
87
|
+
const next = serialize(v, format.value);
|
|
88
|
+
if (next === lastSeeded) return;
|
|
89
|
+
lastSeeded = next;
|
|
90
|
+
text.value = next;
|
|
91
|
+
error.value = "";
|
|
92
|
+
},
|
|
93
|
+
{ deep: true },
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Convert text between formats by round-tripping through `parse` →
|
|
97
|
+
// `serialize`. If the current text doesn't parse, we keep the old
|
|
98
|
+
// format and surface the error.
|
|
99
|
+
function onFormatChange(next: string) {
|
|
100
|
+
const target = next as Format;
|
|
101
|
+
if (target === format.value) return;
|
|
102
|
+
try {
|
|
103
|
+
const parsed = parse(text.value, format.value);
|
|
104
|
+
text.value = serialize(parsed, target);
|
|
105
|
+
format.value = target;
|
|
106
|
+
error.value = "";
|
|
107
|
+
} catch (e) {
|
|
108
|
+
error.value = `Cannot switch format: ${(e as Error).message}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function handleApply() {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = parse(text.value, format.value);
|
|
115
|
+
error.value = "";
|
|
116
|
+
emit("apply", parsed);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
error.value = (e as Error).message;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Auto-apply when the editor loses focus, but only when the text parses
|
|
123
|
+
// cleanly (we don't want to nag with errors as the user clicks away —
|
|
124
|
+
// they can hit Apply explicitly to see what's wrong) and only when the
|
|
125
|
+
// parsed value actually differs from `props.value`.
|
|
126
|
+
function handleBlur() {
|
|
127
|
+
let parsed: Value;
|
|
128
|
+
try {
|
|
129
|
+
parsed = parse(text.value, format.value);
|
|
130
|
+
} catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (serialize(parsed, format.value) === serialize(props.value, format.value))
|
|
134
|
+
return;
|
|
135
|
+
error.value = "";
|
|
136
|
+
emit("apply", parsed);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// CodeMirror's contenteditable doesn't fire `blur` when the user clicks
|
|
140
|
+
// on a non-focusable element (e.g., empty page area or a chart svg), so
|
|
141
|
+
// we watch document pointerdown for clicks outside our wrapper and
|
|
142
|
+
// trigger the blur-apply logic ourselves.
|
|
143
|
+
const rootRef = useTemplateRef<HTMLElement>("rootRef");
|
|
144
|
+
|
|
145
|
+
function onDocumentPointerDown(e: PointerEvent) {
|
|
146
|
+
const target = e.target as Node | null;
|
|
147
|
+
if (!rootRef.value || !target) return;
|
|
148
|
+
if (rootRef.value.contains(target)) return;
|
|
149
|
+
handleBlur();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onMounted(() => {
|
|
153
|
+
document.addEventListener("pointerdown", onDocumentPointerDown);
|
|
154
|
+
});
|
|
155
|
+
onUnmounted(() => {
|
|
156
|
+
document.removeEventListener("pointerdown", onDocumentPointerDown);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const extensions = computed(() => [
|
|
160
|
+
format.value === "json"
|
|
161
|
+
? jsonLang()
|
|
162
|
+
: format.value === "yaml"
|
|
163
|
+
? yamlLang()
|
|
164
|
+
: StreamLanguage.define(tomlMode),
|
|
165
|
+
EditorView.lineWrapping,
|
|
166
|
+
// Bind Cmd/Ctrl+S to Apply and stop the browser from intercepting it
|
|
167
|
+
// for its own "Save page as…" dialog.
|
|
168
|
+
keymap.of([
|
|
169
|
+
{
|
|
170
|
+
key: "Mod-s",
|
|
171
|
+
preventDefault: true,
|
|
172
|
+
run: () => {
|
|
173
|
+
handleApply();
|
|
174
|
+
return true;
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
]),
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
// `YYYYMMDD-HHMMSS` in local time — readable, sortable, and filesystem-safe
|
|
181
|
+
// on Windows (no colons). Computed at export time so the name reflects
|
|
182
|
+
// when the user clicked, not when the editor mounted.
|
|
183
|
+
function defaultFilename(): string {
|
|
184
|
+
const d = new Date();
|
|
185
|
+
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
186
|
+
const ts =
|
|
187
|
+
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
|
188
|
+
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
189
|
+
return `params-${ts}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function handleExport() {
|
|
193
|
+
const ext = format.value === "yaml" ? "yml" : format.value;
|
|
194
|
+
const mime =
|
|
195
|
+
format.value === "json"
|
|
196
|
+
? "application/json"
|
|
197
|
+
: format.value === "yaml"
|
|
198
|
+
? "application/yaml"
|
|
199
|
+
: "application/toml";
|
|
200
|
+
const base = props.filename ?? defaultFilename();
|
|
201
|
+
const blob = new Blob([text.value], { type: mime });
|
|
202
|
+
const url = URL.createObjectURL(blob);
|
|
203
|
+
const a = document.createElement("a");
|
|
204
|
+
a.href = url;
|
|
205
|
+
a.download = `${base}.${ext}`;
|
|
206
|
+
a.click();
|
|
207
|
+
URL.revokeObjectURL(url);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
|
|
211
|
+
|
|
212
|
+
function handleImport() {
|
|
213
|
+
fileInput.value?.click();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function inferFormat(filename: string): Format {
|
|
217
|
+
const n = filename.toLowerCase();
|
|
218
|
+
if (n.endsWith(".yaml") || n.endsWith(".yml")) return "yaml";
|
|
219
|
+
if (n.endsWith(".toml")) return "toml";
|
|
220
|
+
return "json";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function onFileChange(e: Event) {
|
|
224
|
+
const input = e.target as HTMLInputElement;
|
|
225
|
+
const file = input.files?.[0];
|
|
226
|
+
if (!file) return;
|
|
227
|
+
const raw = await file.text();
|
|
228
|
+
const inferred = inferFormat(file.name);
|
|
229
|
+
format.value = inferred;
|
|
230
|
+
text.value = raw;
|
|
231
|
+
try {
|
|
232
|
+
parse(raw, inferred);
|
|
233
|
+
error.value = "";
|
|
234
|
+
} catch (err) {
|
|
235
|
+
error.value = (err as Error).message;
|
|
236
|
+
}
|
|
237
|
+
input.value = "";
|
|
238
|
+
}
|
|
239
|
+
</script>
|
|
240
|
+
|
|
241
|
+
<template>
|
|
242
|
+
<div ref="rootRef" class="param-editor">
|
|
243
|
+
<div class="param-editor-toolbar">
|
|
244
|
+
<SelectBox
|
|
245
|
+
:model-value="format"
|
|
246
|
+
aria-label="Format"
|
|
247
|
+
:options="formatOptions"
|
|
248
|
+
@update:model-value="(v: string | undefined) => v && onFormatChange(v)"
|
|
249
|
+
/>
|
|
250
|
+
<div class="param-editor-actions">
|
|
251
|
+
<Button variant="secondary" @click="handleImport">Import</Button>
|
|
252
|
+
<Button variant="secondary" @click="handleExport">Export</Button>
|
|
253
|
+
<Button @click="handleApply">Apply</Button>
|
|
254
|
+
</div>
|
|
255
|
+
<input
|
|
256
|
+
ref="fileInput"
|
|
257
|
+
type="file"
|
|
258
|
+
accept=".json,.toml,.yaml,.yml,application/json,text/plain"
|
|
259
|
+
class="param-editor-file-input"
|
|
260
|
+
tabindex="-1"
|
|
261
|
+
aria-hidden="true"
|
|
262
|
+
@change="onFileChange"
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
<Codemirror
|
|
266
|
+
v-model="text"
|
|
267
|
+
:extensions="extensions"
|
|
268
|
+
:style="{ height, fontSize }"
|
|
269
|
+
:indent-with-tab="false"
|
|
270
|
+
:tab-size="2"
|
|
271
|
+
class="param-editor-cm"
|
|
272
|
+
@blur="handleBlur"
|
|
273
|
+
/>
|
|
274
|
+
<div class="param-editor-error-region" role="status">
|
|
275
|
+
<Box v-if="error" variant="error" class="param-editor-error">
|
|
276
|
+
{{ error }}
|
|
277
|
+
</Box>
|
|
278
|
+
</div>
|
|
279
|
+
<p class="param-editor-shortcut-hint">
|
|
280
|
+
Press <kbd>{{ applyShortcut }}</kbd> to apply, or click outside the
|
|
281
|
+
editor.
|
|
282
|
+
</p>
|
|
283
|
+
</div>
|
|
284
|
+
</template>
|
|
285
|
+
|
|
286
|
+
<style scoped>
|
|
287
|
+
.param-editor {
|
|
288
|
+
display: flex;
|
|
289
|
+
flex-direction: column;
|
|
290
|
+
gap: 0.5em;
|
|
291
|
+
min-width: 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.param-editor-toolbar {
|
|
295
|
+
display: flex;
|
|
296
|
+
flex-wrap: wrap;
|
|
297
|
+
gap: 0.5em;
|
|
298
|
+
align-items: center;
|
|
299
|
+
justify-content: space-between;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.param-editor-actions {
|
|
303
|
+
display: flex;
|
|
304
|
+
gap: 0.25em;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.param-editor-file-input {
|
|
308
|
+
position: absolute;
|
|
309
|
+
width: 1px;
|
|
310
|
+
height: 1px;
|
|
311
|
+
padding: 0;
|
|
312
|
+
margin: -1px;
|
|
313
|
+
overflow: hidden;
|
|
314
|
+
clip: rect(0, 0, 0, 0);
|
|
315
|
+
border: 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.param-editor-cm {
|
|
319
|
+
border: 1px solid var(--color-border);
|
|
320
|
+
border-radius: 0.375em;
|
|
321
|
+
overflow: hidden;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.param-editor-cm :deep(.cm-editor) {
|
|
325
|
+
height: 100%;
|
|
326
|
+
outline: none;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.param-editor-cm :deep(.cm-editor.cm-focused) {
|
|
330
|
+
outline: none;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.param-editor-cm :deep(.cm-scroller) {
|
|
334
|
+
font-family:
|
|
335
|
+
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
|
336
|
+
"Courier New", monospace;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.param-editor-error {
|
|
340
|
+
white-space: pre-wrap;
|
|
341
|
+
word-break: break-word;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.param-editor-shortcut-hint {
|
|
345
|
+
margin: 0;
|
|
346
|
+
font-size: var(--font-size-xs, 0.75em);
|
|
347
|
+
color: var(--color-text-secondary, #666);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.param-editor-shortcut-hint kbd {
|
|
351
|
+
font-family: inherit;
|
|
352
|
+
padding: 0.1em 0.35em;
|
|
353
|
+
border: 1px solid var(--color-border);
|
|
354
|
+
border-radius: 0.25em;
|
|
355
|
+
background: var(--color-bg-1, transparent);
|
|
356
|
+
font-size: 0.95em;
|
|
357
|
+
}
|
|
358
|
+
</style>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
keywords: [select, dropdown, autocomplete, combobox, filter, search, typeahead]
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# SelectBox
|
|
6
|
+
|
|
7
|
+
A single-select dropdown built on reka-ui. Set `autocomplete` to turn it into a
|
|
8
|
+
filterable single-select combobox: type to narrow the options, just like
|
|
9
|
+
[MultiSelect](./multi-select) but bound to a single `string`.
|
|
10
|
+
|
|
11
|
+
## Examples
|
|
12
|
+
|
|
13
|
+
<script setup>
|
|
14
|
+
import { ref } from 'vue'
|
|
15
|
+
const interval = ref('weekly')
|
|
16
|
+
const state = ref('')
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<ComponentDemo>
|
|
20
|
+
<div style="width: 200px">
|
|
21
|
+
<SelectBox
|
|
22
|
+
v-model="interval"
|
|
23
|
+
label="Interval"
|
|
24
|
+
:options="[
|
|
25
|
+
{ value: 'daily', label: 'Daily' },
|
|
26
|
+
{ value: 'weekly', label: 'Weekly' },
|
|
27
|
+
{ value: 'monthly', label: 'Monthly' },
|
|
28
|
+
]"
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<template #code>
|
|
33
|
+
|
|
34
|
+
```vue
|
|
35
|
+
<script setup>
|
|
36
|
+
import { ref } from "vue";
|
|
37
|
+
const interval = ref("weekly");
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<SelectBox
|
|
41
|
+
v-model="interval"
|
|
42
|
+
label="Interval"
|
|
43
|
+
:options="[
|
|
44
|
+
{ value: 'daily', label: 'Daily' },
|
|
45
|
+
{ value: 'weekly', label: 'Weekly' },
|
|
46
|
+
{ value: 'monthly', label: 'Monthly' },
|
|
47
|
+
]"
|
|
48
|
+
/>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
</template>
|
|
52
|
+
</ComponentDemo>
|
|
53
|
+
|
|
54
|
+
### Hidden label
|
|
55
|
+
|
|
56
|
+
Use `hide-label` to visually hide the label while keeping it available to
|
|
57
|
+
screen readers. Prefer this over `aria-label` whenever you have label text,
|
|
58
|
+
since a real `<label>` is translated by browsers and keeps the naming in the
|
|
59
|
+
DOM.
|
|
60
|
+
|
|
61
|
+
<ComponentDemo>
|
|
62
|
+
<div style="width: 200px">
|
|
63
|
+
<SelectBox
|
|
64
|
+
v-model="interval"
|
|
65
|
+
label="Interval"
|
|
66
|
+
hide-label
|
|
67
|
+
:options="[
|
|
68
|
+
{ value: 'daily', label: 'Daily' },
|
|
69
|
+
{ value: 'weekly', label: 'Weekly' },
|
|
70
|
+
{ value: 'monthly', label: 'Monthly' },
|
|
71
|
+
]"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<template #code>
|
|
76
|
+
|
|
77
|
+
```vue
|
|
78
|
+
<SelectBox
|
|
79
|
+
v-model="interval"
|
|
80
|
+
label="Interval"
|
|
81
|
+
hide-label
|
|
82
|
+
:options="[
|
|
83
|
+
{ value: 'daily', label: 'Daily' },
|
|
84
|
+
{ value: 'weekly', label: 'Weekly' },
|
|
85
|
+
{ value: 'monthly', label: 'Monthly' },
|
|
86
|
+
]"
|
|
87
|
+
/>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
</template>
|
|
91
|
+
</ComponentDemo>
|
|
92
|
+
|
|
93
|
+
### Autocomplete
|
|
94
|
+
|
|
95
|
+
Add `autocomplete` to make the field filterable. Type to narrow the list; the
|
|
96
|
+
selected option's label fills the input. `v-model` is still a single `string`.
|
|
97
|
+
|
|
98
|
+
<ComponentDemo>
|
|
99
|
+
<div style="width: 240px">
|
|
100
|
+
<SelectBox
|
|
101
|
+
v-model="state"
|
|
102
|
+
autocomplete
|
|
103
|
+
label="State"
|
|
104
|
+
placeholder="Search states…"
|
|
105
|
+
:options="[
|
|
106
|
+
{ value: 'ca', label: 'California' },
|
|
107
|
+
{ value: 'ny', label: 'New York' },
|
|
108
|
+
{ value: 'tx', label: 'Texas' },
|
|
109
|
+
{ value: 'wa', label: 'Washington' },
|
|
110
|
+
{ value: 'fl', label: 'Florida' },
|
|
111
|
+
]"
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<template #code>
|
|
116
|
+
|
|
117
|
+
```vue
|
|
118
|
+
<script setup>
|
|
119
|
+
import { ref } from "vue";
|
|
120
|
+
const state = ref("");
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<SelectBox
|
|
124
|
+
v-model="state"
|
|
125
|
+
autocomplete
|
|
126
|
+
label="State"
|
|
127
|
+
placeholder="Search states…"
|
|
128
|
+
:options="[
|
|
129
|
+
{ value: 'ca', label: 'California' },
|
|
130
|
+
{ value: 'ny', label: 'New York' },
|
|
131
|
+
{ value: 'tx', label: 'Texas' },
|
|
132
|
+
{ value: 'wa', label: 'Washington' },
|
|
133
|
+
{ value: 'fl', label: 'Florida' },
|
|
134
|
+
]"
|
|
135
|
+
/>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
</template>
|
|
139
|
+
</ComponentDemo>
|
|
140
|
+
|
|
141
|
+
## Accessibility
|
|
142
|
+
|
|
143
|
+
In the default mode the field is a reka-ui Select; with `autocomplete` it's a
|
|
144
|
+
reka-ui Combobox following the
|
|
145
|
+
[ARIA combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/):
|
|
146
|
+
|
|
147
|
+
- The input has `role="combobox"` with `aria-expanded`, `aria-controls`, and
|
|
148
|
+
`aria-autocomplete="list"`; the popup is a `role="listbox"` and each option a
|
|
149
|
+
`role="option"` that reflects its state via `aria-selected`.
|
|
150
|
+
- Keyboard support is handled for you: type to filter, ↑/↓ to move through the
|
|
151
|
+
list, Enter to select the highlighted option, and Escape to close.
|
|
152
|
+
- Pass `label` so the field is named by a real `<label>`. In autocomplete mode
|
|
153
|
+
the label is also wired to the input via `for`/`id`, so clicking it focuses
|
|
154
|
+
the field; use `hide-label` to keep the label for screen readers while hiding
|
|
155
|
+
it visually, or `aria-label` when there's no visible label text.
|
|
156
|
+
- The chevron is a labelled toggle (`aria-label="Toggle options"`) kept out of
|
|
157
|
+
the tab order, and native browser autofill is disabled so it can't cover the
|
|
158
|
+
option list.
|
|
159
|
+
|
|
160
|
+
<!--@include: ./_api/select-box.md-->
|
|
161
|
+
|
|
162
|
+
### SelectOption
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
interface SelectOption {
|
|
166
|
+
value: string;
|
|
167
|
+
label: string;
|
|
168
|
+
}
|
|
169
|
+
```
|