@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.
Files changed (89) hide show
  1. package/dist/Box/Box.d.ts +7 -11
  2. package/dist/Button/Button.d.ts +7 -11
  3. package/dist/ButtonGroup/ButtonGroup.d.ts +8 -12
  4. package/dist/Container/Container.d.ts +7 -11
  5. package/dist/Expander/Expander.d.ts +12 -14
  6. package/dist/Grid/Grid.d.ts +6 -5
  7. package/dist/Hint/Hint.d.ts +2 -1
  8. package/dist/Icon/Icon.d.ts +3 -2
  9. package/dist/LightDarkToggle/LightDarkToggle.d.ts +2 -1
  10. package/dist/MultiSelect/MultiSelect.d.ts +6 -6
  11. package/dist/NumberInput/NumberInput.d.ts +13 -11
  12. package/dist/ParamEditor/ParamEditor.d.ts +3 -2
  13. package/dist/ParamEditor/ParamEditorImpl.d.ts +3 -5
  14. package/dist/{ParamEditorImpl-D0xZTyAN.js → ParamEditorImpl-CkElC6PI.js} +3075 -3041
  15. package/dist/SelectBox/SelectBox.d.ts +8 -6
  16. package/dist/{SelectBox-CByXZfaC.js → SelectBox-Df8dE2r-.js} +6 -6
  17. package/dist/SidebarLayout/SidebarLayout.d.ts +21 -20
  18. package/dist/Spinner/Spinner.d.ts +3 -2
  19. package/dist/TextInput/TextInput.d.ts +7 -5
  20. package/dist/Toggle/Toggle.d.ts +8 -6
  21. package/dist/ToggleGroup/ToggleGroup.d.ts +8 -6
  22. package/dist/_internal/FieldLabel.d.ts +2 -1
  23. package/dist/index.js +20 -20
  24. package/docs/Box.md +49 -0
  25. package/docs/Button.md +67 -0
  26. package/docs/ButtonGroup.md +64 -0
  27. package/docs/Container.md +103 -0
  28. package/docs/Expander.md +34 -0
  29. package/docs/Grid.md +170 -0
  30. package/docs/Hint.md +29 -0
  31. package/docs/Icon.md +188 -0
  32. package/docs/MultiSelect.md +143 -0
  33. package/docs/NumberInput.md +485 -0
  34. package/docs/ParamEditor.md +97 -0
  35. package/docs/SelectBox.md +182 -0
  36. package/docs/SidebarLayout.md +106 -0
  37. package/docs/Spinner.md +51 -0
  38. package/docs/TextInput.md +83 -0
  39. package/docs/Toggle.md +81 -0
  40. package/docs/ToggleGroup.md +163 -0
  41. package/docs/index.json +194 -0
  42. package/package.json +24 -20
  43. package/src/Box/Box.md +41 -0
  44. package/src/Box/Box.vue +52 -0
  45. package/src/Button/Button.md +59 -0
  46. package/src/Button/Button.vue +81 -0
  47. package/src/ButtonGroup/ButtonGroup.md +62 -0
  48. package/src/ButtonGroup/ButtonGroup.vue +91 -0
  49. package/src/Container/Container.md +99 -0
  50. package/src/Container/Container.vue +62 -0
  51. package/src/Expander/Expander.md +23 -0
  52. package/src/Expander/Expander.vue +95 -0
  53. package/src/Grid/Grid.md +175 -0
  54. package/src/Grid/Grid.vue +145 -0
  55. package/src/Hint/Hint.md +24 -0
  56. package/src/Hint/Hint.vue +83 -0
  57. package/src/Icon/Icon.md +176 -0
  58. package/src/Icon/Icon.vue +104 -0
  59. package/src/Icon/defaultIcons.ts +92 -0
  60. package/src/Icon/github.svg +1 -0
  61. package/src/Icon/registry.ts +68 -0
  62. package/src/LightDarkToggle/LightDarkToggle.vue +49 -0
  63. package/src/MultiSelect/MultiSelect.md +142 -0
  64. package/src/MultiSelect/MultiSelect.vue +279 -0
  65. package/src/NumberInput/NumberInput.md +455 -0
  66. package/src/NumberInput/NumberInput.vue +575 -0
  67. package/src/ParamEditor/ParamEditor.md +87 -0
  68. package/src/ParamEditor/ParamEditor.vue +43 -0
  69. package/src/ParamEditor/ParamEditorImpl.vue +358 -0
  70. package/src/SelectBox/SelectBox.md +169 -0
  71. package/src/SelectBox/SelectBox.vue +260 -0
  72. package/src/SidebarLayout/SidebarLayout.md +106 -0
  73. package/src/SidebarLayout/SidebarLayout.vue +468 -0
  74. package/src/Spinner/Spinner.md +45 -0
  75. package/src/Spinner/Spinner.vue +55 -0
  76. package/src/TextInput/TextInput.md +68 -0
  77. package/src/TextInput/TextInput.vue +54 -0
  78. package/src/Toggle/Toggle.md +68 -0
  79. package/src/Toggle/Toggle.vue +81 -0
  80. package/src/ToggleGroup/ToggleGroup.md +158 -0
  81. package/src/ToggleGroup/ToggleGroup.vue +138 -0
  82. package/src/_internal/FieldLabel.vue +27 -0
  83. package/src/_internal/field.ts +27 -0
  84. package/src/_internal/gap.ts +17 -0
  85. package/src/_internal/input.css +54 -0
  86. package/src/_internal/listbox.css +53 -0
  87. package/src/env.d.ts +4 -0
  88. package/src/index.ts +33 -0
  89. 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
+ ```