@cfasim-ui/docs 0.6.3 → 0.7.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 (38) hide show
  1. package/charts/BarChart/BarChart.md +7 -8
  2. package/charts/BarChart/BarChart.vue +13 -7
  3. package/charts/ChoroplethMap/ChoroplethMap.md +99 -29
  4. package/charts/ChoroplethMap/ChoroplethMap.vue +456 -127
  5. package/charts/DataTable/DataTable.vue +1 -7
  6. package/charts/LineChart/LineChart.md +7 -8
  7. package/charts/LineChart/LineChart.vue +13 -7
  8. package/charts/_shared/ChartZoomControls.vue +138 -0
  9. package/charts/_shared/chartProps.ts +7 -7
  10. package/charts/_shared/index.ts +4 -15
  11. package/charts/_shared/seriesCsv.ts +2 -2
  12. package/charts/_shared/touch.ts +8 -0
  13. package/charts/_shared/useChartFoundation.ts +0 -1
  14. package/components/MultiSelect/MultiSelect.md +1 -1
  15. package/components/MultiSelect/MultiSelect.vue +24 -83
  16. package/components/NumberInput/NumberInput.md +5 -5
  17. package/components/NumberInput/NumberInput.vue +12 -59
  18. package/components/SelectBox/SelectBox.md +1 -0
  19. package/components/SelectBox/SelectBox.vue +34 -125
  20. package/components/TextInput/TextInput.md +2 -1
  21. package/components/TextInput/TextInput.vue +11 -51
  22. package/components/ToggleGroup/ToggleGroup.md +2 -2
  23. package/components/ToggleGroup/ToggleGroup.vue +21 -26
  24. package/components/_internal/FieldLabel.vue +27 -0
  25. package/components/_internal/field.ts +27 -0
  26. package/components/_internal/input.css +54 -0
  27. package/components/_internal/listbox.css +53 -0
  28. package/components/env.d.ts +4 -0
  29. package/index.json +8 -8
  30. package/package.json +1 -1
  31. package/pyodide/messages.ts +23 -0
  32. package/pyodide/pyodide.worker.ts +2 -21
  33. package/pyodide/pyodideWorkerApi.ts +7 -44
  34. package/shared/index.ts +1 -0
  35. package/shared/workerError.ts +17 -0
  36. package/wasm/messages.ts +6 -0
  37. package/wasm/wasm.worker.ts +1 -7
  38. package/wasm/wasmWorkerApi.ts +8 -26
@@ -3,6 +3,8 @@ import { ref, watch, computed, onMounted, getCurrentInstance } from "vue";
3
3
  import { SliderRoot, SliderTrack, SliderRange, SliderThumb } from "reka-ui";
4
4
  import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
5
5
  import Hint from "../Hint/Hint.vue";
6
+ import type { FieldProps } from "../_internal/field";
7
+ import "../_internal/input.css";
6
8
 
7
9
  export type NumberRange = [number, number];
8
10
 
@@ -16,14 +18,11 @@ const range = defineModel<NumberRange>("range");
16
18
  const lower = defineModel<number>("lower");
17
19
  const upper = defineModel<number>("upper");
18
20
 
19
- const props = defineProps<{
20
- label?: string;
21
- hideLabel?: boolean;
21
+ interface Props extends FieldProps {
22
22
  placeholder?: string;
23
23
  step?: number;
24
24
  min?: number;
25
25
  max?: number;
26
- hint?: string;
27
26
  percent?: boolean;
28
27
  slider?: boolean;
29
28
  live?: boolean;
@@ -42,7 +41,9 @@ const props = defineProps<{
42
41
  /** @deprecated Use `format` instead. Still honored for slider labels
43
42
  * when `format` is unset, but will be removed in a future release. */
44
43
  sliderDisplay?: (value: number) => string;
45
- }>();
44
+ }
45
+
46
+ const props = defineProps<Props>();
46
47
 
47
48
  function isRangeValue(v: unknown): v is NumberRange {
48
49
  return Array.isArray(v) && v.length === 2;
@@ -75,7 +76,7 @@ onMounted(() => {
75
76
  }
76
77
  });
77
78
 
78
- const sliderMin = computed(() => props.min ?? (props.percent ? 0 : 0));
79
+ const sliderMin = computed(() => props.min ?? 0);
79
80
  const sliderMax = computed(() => props.max ?? (props.percent ? 1 : 100));
80
81
  const sliderStep = computed(() => props.step ?? (props.percent ? 0.01 : 1));
81
82
 
@@ -408,11 +409,11 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
408
409
  <template>
409
410
  <component
410
411
  :is="props.label ? 'label' : 'div'"
411
- :class="props.label ? 'input-label' : undefined"
412
+ :class="props.label ? 'cfasim-input-label' : undefined"
412
413
  >
413
414
  <span
414
415
  v-if="props.label"
415
- class="input-label-row"
416
+ class="cfasim-input-label-row"
416
417
  :class="{ 'visually-hidden': props.hideLabel }"
417
418
  >
418
419
  {{ props.label }}
@@ -421,9 +422,11 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
421
422
  <span v-if="!isSlider" class="input-wrapper">
422
423
  <input
423
424
  type="text"
425
+ class="cfasim-input"
424
426
  :inputmode="props.numberType === 'integer' ? 'numeric' : 'decimal'"
425
427
  v-model="local"
426
428
  :placeholder="props.placeholder"
429
+ :aria-label="!props.label ? props.ariaLabel : undefined"
427
430
  :aria-invalid="!!validationError"
428
431
  :aria-required="props.required || undefined"
429
432
  :required="props.required"
@@ -475,67 +478,17 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
475
478
  </template>
476
479
 
477
480
  <style scoped>
478
- .input-label {
479
- display: flex;
480
- flex-direction: column;
481
- gap: 0.25em;
482
- }
483
-
484
- .input-label-row {
485
- display: flex;
486
- align-items: center;
487
- justify-content: space-between;
488
- }
489
-
490
481
  .input-wrapper {
491
482
  display: flex;
492
483
  align-items: center;
493
484
  gap: 0.25em;
494
485
  }
495
486
 
496
- .input-wrapper input {
487
+ .input-wrapper .cfasim-input {
497
488
  flex: 1;
498
489
  min-width: 0;
499
490
  }
500
491
 
501
- input {
502
- display: block;
503
- width: 100%;
504
- height: 2.5em;
505
- padding: 0 0.75em;
506
- font-size: inherit;
507
- background-color: var(--color-bg-0);
508
- color: var(--color-text);
509
- border: 1px solid var(--color-border);
510
- border-radius: 0.375em;
511
- transition:
512
- border-color var(--transition-fast),
513
- box-shadow var(--transition-fast);
514
- }
515
-
516
- input:hover {
517
- border-color: var(--color-border-hover);
518
- }
519
-
520
- input:focus {
521
- outline: none;
522
- border-color: var(--color-border-focus);
523
- box-shadow: var(--shadow-focus);
524
- }
525
-
526
- input[aria-invalid="true"] {
527
- border-color: var(--color-error);
528
- }
529
-
530
- input[aria-invalid="true"]:focus {
531
- border-color: var(--color-error);
532
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 25%, transparent);
533
- }
534
-
535
- input::placeholder {
536
- color: var(--color-text-tertiary);
537
- }
538
-
539
492
  .input-suffix {
540
493
  color: var(--color-text-secondary);
541
494
  font-size: var(--font-size-sm);
@@ -166,6 +166,7 @@ reka-ui Combobox following the
166
166
  | `label` | `string` | No | — |
167
167
  | `hideLabel` | `boolean` | No | — |
168
168
  | `ariaLabel` | `string` | No | — |
169
+ | `hint` | `string` | No | — |
169
170
  | `options` | `SelectOption[]` | Yes | — |
170
171
  | `placeholder` | `string` | No | — |
171
172
  | `autocomplete` | `boolean` | No | — |
@@ -19,8 +19,11 @@ import {
19
19
  SelectTrigger,
20
20
  SelectValue,
21
21
  SelectViewport,
22
- useId,
23
22
  } from "reka-ui";
23
+ import Icon from "../Icon/Icon.vue";
24
+ import FieldLabel from "../_internal/FieldLabel.vue";
25
+ import { useField, type FieldProps } from "../_internal/field";
26
+ import "../_internal/listbox.css";
24
27
 
25
28
  export interface SelectOption {
26
29
  value: string;
@@ -29,17 +32,16 @@ export interface SelectOption {
29
32
 
30
33
  const model = defineModel<string>();
31
34
 
32
- const props = defineProps<{
33
- label?: string;
34
- hideLabel?: boolean;
35
- ariaLabel?: string;
35
+ interface Props extends FieldProps {
36
36
  options: SelectOption[];
37
37
  placeholder?: string;
38
38
  /** Turn the field into a filterable single-select autocomplete (combobox). */
39
39
  autocomplete?: boolean;
40
- }>();
40
+ }
41
+
42
+ const props = defineProps<Props>();
41
43
 
42
- const id = useId();
44
+ const { id, labelId, ariaProps } = useField(props);
43
45
 
44
46
  // Shows the selected option's label in the combobox input when not filtering.
45
47
  function displayValue(value: string) {
@@ -49,14 +51,14 @@ function displayValue(value: string) {
49
51
 
50
52
  <template>
51
53
  <div class="select-box">
52
- <label
53
- v-if="label"
54
- :id="`${id}-label`"
55
- :for="autocomplete ? `${id}-input` : undefined"
54
+ <FieldLabel
56
55
  class="select-label"
57
- :class="{ 'visually-hidden': hideLabel }"
58
- >{{ label }}</label
59
- >
56
+ :label="label"
57
+ :label-id="labelId"
58
+ :hide-label="hideLabel"
59
+ :hint="hint"
60
+ :html-for="autocomplete ? `${id}-input` : undefined"
61
+ />
60
62
 
61
63
  <ComboboxRoot
62
64
  v-if="autocomplete"
@@ -70,55 +72,34 @@ function displayValue(value: string) {
70
72
  class="select-input"
71
73
  :display-value="displayValue"
72
74
  :placeholder="placeholder"
73
- :aria-labelledby="props.label ? `${id}-label` : undefined"
74
- :aria-label="!props.label ? props.ariaLabel : undefined"
75
+ v-bind="ariaProps"
75
76
  />
76
77
  <ComboboxTrigger class="select-icon-button" aria-label="Toggle options">
77
78
  <span class="select-icon" aria-hidden="true">
78
- <svg
79
- width="12"
80
- height="12"
81
- viewBox="0 0 12 12"
82
- fill="none"
83
- stroke="currentColor"
84
- stroke-width="2"
85
- stroke-linecap="round"
86
- stroke-linejoin="round"
87
- >
88
- <path d="M3 4.5L6 7.5L9 4.5" />
89
- </svg>
79
+ <Icon icon="keyboard_arrow_down" :size="16" />
90
80
  </span>
91
81
  </ComboboxTrigger>
92
82
  </ComboboxAnchor>
93
83
  <ComboboxPortal>
94
84
  <ComboboxContent
95
- class="select-content select-content-autocomplete"
85
+ class="cfasim-listbox-content select-content-autocomplete"
96
86
  position="popper"
97
87
  :side-offset="4"
98
88
  :body-lock="false"
99
89
  >
100
- <ComboboxViewport class="select-viewport">
101
- <ComboboxEmpty class="select-empty">No matches</ComboboxEmpty>
90
+ <ComboboxViewport class="cfasim-listbox-viewport">
91
+ <ComboboxEmpty class="cfasim-listbox-empty">
92
+ No matches
93
+ </ComboboxEmpty>
102
94
  <ComboboxItem
103
95
  v-for="opt in options"
104
96
  :key="opt.value"
105
97
  :value="opt.value"
106
- class="select-item"
98
+ class="cfasim-listbox-item"
107
99
  >
108
100
  <span>{{ opt.label }}</span>
109
- <ComboboxItemIndicator class="select-indicator">
110
- <svg
111
- width="12"
112
- height="12"
113
- viewBox="0 0 12 12"
114
- fill="none"
115
- stroke="currentColor"
116
- stroke-width="2"
117
- stroke-linecap="round"
118
- stroke-linejoin="round"
119
- >
120
- <path d="M2 6L5 9L10 3" />
121
- </svg>
101
+ <ComboboxItemIndicator class="cfasim-listbox-indicator">
102
+ <Icon icon="check" :size="14" />
122
103
  </ComboboxItemIndicator>
123
104
  </ComboboxItem>
124
105
  </ComboboxViewport>
@@ -127,55 +108,29 @@ function displayValue(value: string) {
127
108
  </ComboboxRoot>
128
109
 
129
110
  <SelectRoot v-else v-model="model">
130
- <SelectTrigger
131
- class="select-trigger"
132
- :aria-labelledby="props.label ? `${id}-label` : undefined"
133
- :aria-label="!props.label ? props.ariaLabel : undefined"
134
- >
111
+ <SelectTrigger class="select-trigger" v-bind="ariaProps">
135
112
  <SelectValue :placeholder="placeholder" />
136
113
  <span class="select-icon" aria-hidden="true">
137
- <svg
138
- width="12"
139
- height="12"
140
- viewBox="0 0 12 12"
141
- fill="none"
142
- stroke="currentColor"
143
- stroke-width="2"
144
- stroke-linecap="round"
145
- stroke-linejoin="round"
146
- >
147
- <path d="M3 4.5L6 7.5L9 4.5" />
148
- </svg>
114
+ <Icon icon="keyboard_arrow_down" :size="16" />
149
115
  </span>
150
116
  </SelectTrigger>
151
117
  <SelectPortal>
152
118
  <SelectContent
153
- class="select-content"
119
+ class="cfasim-listbox-content select-content"
154
120
  position="popper"
155
121
  :side-offset="4"
156
122
  :body-lock="false"
157
123
  >
158
- <SelectViewport class="select-viewport">
124
+ <SelectViewport class="cfasim-listbox-viewport">
159
125
  <SelectItem
160
126
  v-for="opt in options"
161
127
  :key="opt.value"
162
128
  :value="opt.value"
163
- class="select-item"
129
+ class="cfasim-listbox-item"
164
130
  >
165
131
  <SelectItemText>{{ opt.label }}</SelectItemText>
166
- <SelectItemIndicator class="select-indicator">
167
- <svg
168
- width="12"
169
- height="12"
170
- viewBox="0 0 12 12"
171
- fill="none"
172
- stroke="currentColor"
173
- stroke-width="2"
174
- stroke-linecap="round"
175
- stroke-linejoin="round"
176
- >
177
- <path d="M2 6L5 9L10 3" />
178
- </svg>
132
+ <SelectItemIndicator class="cfasim-listbox-indicator">
133
+ <Icon icon="check" :size="14" />
179
134
  </SelectItemIndicator>
180
135
  </SelectItem>
181
136
  </SelectViewport>
@@ -291,14 +246,8 @@ function displayValue(value: string) {
291
246
  </style>
292
247
 
293
248
  <style>
249
+ /* Sizing only — the dropdown skin lives in _internal/listbox.css. */
294
250
  .select-content {
295
- z-index: 100;
296
- background: var(--color-bg-0);
297
- border: 1px solid var(--color-border);
298
- border-radius: 0.25em;
299
- box-shadow:
300
- 0 4px 6px -1px rgba(0, 0, 0, 0.1),
301
- 0 2px 4px -2px rgba(0, 0, 0, 0.1);
302
251
  min-width: var(--reka-select-trigger-width);
303
252
  max-height: var(--reka-select-content-available-height);
304
253
  }
@@ -308,44 +257,4 @@ function displayValue(value: string) {
308
257
  width: var(--reka-combobox-trigger-width);
309
258
  max-height: var(--reka-combobox-content-available-height);
310
259
  }
311
-
312
- .select-viewport {
313
- padding: 0.25em;
314
- }
315
-
316
- .select-empty {
317
- padding: 0.5em;
318
- font-size: var(--font-size-sm);
319
- color: var(--color-text-secondary);
320
- text-align: center;
321
- }
322
-
323
- .select-item {
324
- display: flex;
325
- align-items: center;
326
- justify-content: space-between;
327
- gap: 0.5em;
328
- padding: 0.25em 0.5em;
329
- border-radius: 0.25em;
330
- font-size: var(--font-size-sm);
331
- white-space: nowrap;
332
- cursor: pointer;
333
- user-select: none;
334
- outline: none;
335
- }
336
-
337
- .select-item[data-highlighted] {
338
- background: var(--color-primary);
339
- color: white;
340
- }
341
-
342
- .select-item[data-state="checked"] {
343
- font-weight: 600;
344
- }
345
-
346
- .select-indicator {
347
- display: flex;
348
- align-items: center;
349
- flex-shrink: 0;
350
- }
351
260
  </style>
@@ -77,6 +77,7 @@ it.
77
77
  |------|------|----------|---------|
78
78
  | `label` | `string` | No | — |
79
79
  | `hideLabel` | `boolean` | No | — |
80
- | `placeholder` | `string` | No | — |
80
+ | `ariaLabel` | `string` | No | — |
81
81
  | `hint` | `string` | No | — |
82
+ | `placeholder` | `string` | No | — |
82
83
 
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, watch } from "vue";
3
3
  import Hint from "../Hint/Hint.vue";
4
+ import type { FieldProps } from "../_internal/field";
5
+ import "../_internal/input.css";
4
6
 
5
7
  const model = defineModel<string>();
6
8
  const local = ref(model.value);
@@ -13,18 +15,17 @@ function commit() {
13
15
  model.value = local.value;
14
16
  }
15
17
 
16
- const props = defineProps<{
17
- label?: string;
18
- hideLabel?: boolean;
18
+ interface Props extends FieldProps {
19
19
  placeholder?: string;
20
- hint?: string;
21
- }>();
20
+ }
21
+
22
+ const props = defineProps<Props>();
22
23
  </script>
23
24
 
24
25
  <template>
25
- <label v-if="props.label" class="input-label">
26
+ <label v-if="props.label" class="cfasim-input-label">
26
27
  <span
27
- class="input-label-row"
28
+ class="cfasim-input-label-row"
28
29
  :class="{ 'visually-hidden': props.hideLabel }"
29
30
  >
30
31
  {{ props.label }}
@@ -32,6 +33,7 @@ const props = defineProps<{
32
33
  </span>
33
34
  <input
34
35
  type="text"
36
+ class="cfasim-input"
35
37
  v-model="local"
36
38
  :placeholder="props.placeholder"
37
39
  @blur="commit"
@@ -41,54 +43,12 @@ const props = defineProps<{
41
43
  <div v-else>
42
44
  <input
43
45
  type="text"
46
+ class="cfasim-input"
44
47
  v-model="local"
45
48
  :placeholder="props.placeholder"
49
+ :aria-label="props.ariaLabel"
46
50
  @blur="commit"
47
51
  @keydown.enter="commit"
48
52
  />
49
53
  </div>
50
54
  </template>
51
-
52
- <style scoped>
53
- .input-label {
54
- display: flex;
55
- flex-direction: column;
56
- gap: 0.25em;
57
- font-size: var(--font-size-sm);
58
- }
59
-
60
- .input-label-row {
61
- display: flex;
62
- align-items: center;
63
- justify-content: space-between;
64
- }
65
-
66
- input {
67
- display: block;
68
- width: 100%;
69
- height: 2.5em;
70
- padding: 0 0.75em;
71
- font-size: inherit;
72
- background-color: var(--color-bg-0);
73
- color: var(--color-text);
74
- border: 1px solid var(--color-border);
75
- border-radius: 0.375em;
76
- transition:
77
- border-color var(--transition-fast),
78
- box-shadow var(--transition-fast);
79
- }
80
-
81
- input:hover {
82
- border-color: var(--color-border-hover);
83
- }
84
-
85
- input:focus {
86
- outline: none;
87
- border-color: var(--color-border-focus);
88
- box-shadow: var(--shadow-focus);
89
- }
90
-
91
- input::placeholder {
92
- color: var(--color-text-tertiary);
93
- }
94
- </style>
@@ -142,12 +142,12 @@ Set `orientation="vertical"` to stack the items, and mark individual options
142
142
 
143
143
  | Prop | Type | Required | Default |
144
144
  |------|------|----------|---------|
145
- | `options` | `ToggleGroupOption[]` | Yes | — |
146
- | `multiple` | `boolean` | No | — |
147
145
  | `label` | `string` | No | — |
148
146
  | `hideLabel` | `boolean` | No | — |
149
147
  | `ariaLabel` | `string` | No | — |
150
148
  | `hint` | `string` | No | — |
149
+ | `options` | `ToggleGroupOption[]` | Yes | — |
150
+ | `multiple` | `boolean` | No | — |
151
151
  | `disabled` | `boolean` | No | — |
152
152
  | `orientation` | `"horizontal" \| "vertical"` | No | `"horizontal"` |
153
153
 
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { ToggleGroupItem, ToggleGroupRoot, useId } from "reka-ui";
2
+ import { ToggleGroupItem, ToggleGroupRoot } from "reka-ui";
3
3
  import { computed } from "vue";
4
- import Hint from "../Hint/Hint.vue";
4
+ import FieldLabel from "../_internal/FieldLabel.vue";
5
+ import { useField, type FieldProps } from "../_internal/field";
5
6
 
6
7
  export interface ToggleGroupOption {
7
8
  value: string;
@@ -12,21 +13,18 @@ export interface ToggleGroupOption {
12
13
  // `string` in single mode, `string[]` in multiple mode.
13
14
  const model = defineModel<string | string[]>();
14
15
 
15
- const props = withDefaults(
16
- defineProps<{
17
- options: ToggleGroupOption[];
18
- multiple?: boolean;
19
- label?: string;
20
- hideLabel?: boolean;
21
- ariaLabel?: string;
22
- hint?: string;
23
- disabled?: boolean;
24
- orientation?: "horizontal" | "vertical";
25
- }>(),
26
- { orientation: "horizontal" },
27
- );
16
+ interface Props extends FieldProps {
17
+ options: ToggleGroupOption[];
18
+ multiple?: boolean;
19
+ disabled?: boolean;
20
+ orientation?: "horizontal" | "vertical";
21
+ }
28
22
 
29
- const id = useId();
23
+ const props = withDefaults(defineProps<Props>(), {
24
+ orientation: "horizontal",
25
+ });
26
+
27
+ const { labelId, ariaProps } = useField(props);
30
28
  const type = computed<"single" | "multiple">(() =>
31
29
  props.multiple ? "multiple" : "single",
32
30
  );
@@ -34,23 +32,20 @@ const type = computed<"single" | "multiple">(() =>
34
32
 
35
33
  <template>
36
34
  <div class="toggle-group-field">
37
- <label
38
- v-if="label"
39
- :id="`${id}-label`"
35
+ <FieldLabel
40
36
  class="toggle-group-label"
41
- :class="{ 'visually-hidden': hideLabel }"
42
- >
43
- {{ label }}
44
- <Hint v-if="hint && !hideLabel" :text="hint" />
45
- </label>
37
+ :label="label"
38
+ :label-id="labelId"
39
+ :hide-label="hideLabel"
40
+ :hint="hint"
41
+ />
46
42
  <ToggleGroupRoot
47
43
  v-model="model"
48
44
  :type="type"
49
45
  :disabled="disabled"
50
46
  :orientation="orientation"
51
47
  :class="['toggle-group', `toggle-group--${orientation}`]"
52
- :aria-labelledby="label ? `${id}-label` : undefined"
53
- :aria-label="!label ? ariaLabel : undefined"
48
+ v-bind="ariaProps"
54
49
  >
55
50
  <ToggleGroupItem
56
51
  v-for="opt in options"
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ import Hint from "../Hint/Hint.vue";
3
+
4
+ // The visible label for a form field, with the shared hideLabel/hint
5
+ // behavior. Renders nothing without a `label`; the parent supplies its
6
+ // own class via fallthrough attrs so existing scoped styles keep working.
7
+ defineProps<{
8
+ label?: string;
9
+ labelId?: string;
10
+ hideLabel?: boolean;
11
+ hint?: string;
12
+ /** `for` attribute, for fields whose control is a real labelable element. */
13
+ htmlFor?: string;
14
+ }>();
15
+ </script>
16
+
17
+ <template>
18
+ <label
19
+ v-if="label"
20
+ :id="labelId"
21
+ :for="htmlFor"
22
+ :class="{ 'visually-hidden': hideLabel }"
23
+ >
24
+ {{ label }}
25
+ <Hint v-if="hint && !hideLabel" :text="hint" />
26
+ </label>
27
+ </template>
@@ -0,0 +1,27 @@
1
+ import { computed } from "vue";
2
+ import { useId } from "reka-ui";
3
+
4
+ /** Props shared by every labeled form field. */
5
+ export interface FieldProps {
6
+ label?: string;
7
+ /** Visually hide the label while keeping it for assistive tech. */
8
+ hideLabel?: boolean;
9
+ /** Accessible name used when no visible `label` is provided. */
10
+ ariaLabel?: string;
11
+ hint?: string;
12
+ }
13
+
14
+ /**
15
+ * Generated field/label ids plus the aria attributes that point a control
16
+ * at its label: `aria-labelledby` when a visible `label` exists, else
17
+ * `aria-label`. Spread onto the control with `v-bind="ariaProps.value"`.
18
+ */
19
+ export function useField(props: FieldProps) {
20
+ const id = useId();
21
+ const labelId = `${id}-label`;
22
+ const ariaProps = computed(() => ({
23
+ "aria-labelledby": props.label ? labelId : undefined,
24
+ "aria-label": !props.label ? props.ariaLabel : undefined,
25
+ }));
26
+ return { id, labelId, ariaProps };
27
+ }