@pienter/ui 0.2.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/CHANGELOG.md +64 -0
- package/CONVENTIONS.md +1499 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/components/action/button/Button.astro +95 -0
- package/components/action/button/Button.vue +92 -0
- package/components/action/button/IconButton.astro +86 -0
- package/components/action/button/IconButton.vue +85 -0
- package/components/action/button/button.css +194 -0
- package/components/action/toggle/Toggle.vue +38 -0
- package/components/action/toggle/toggle.css +77 -0
- package/components/action/toggle-group/ToggleGroup.vue +67 -0
- package/components/action/toggle-group/toggle-group.css +78 -0
- package/components/display/avatar/Avatar.astro +17 -0
- package/components/display/avatar/Avatar.vue +30 -0
- package/components/display/avatar/AvatarStack.astro +9 -0
- package/components/display/avatar/AvatarStack.vue +11 -0
- package/components/display/avatar/avatar.css +58 -0
- package/components/display/badge/Badge.astro +15 -0
- package/components/display/badge/Badge.vue +23 -0
- package/components/display/badge/badge.css +56 -0
- package/components/display/empty/Empty.astro +9 -0
- package/components/display/empty/Empty.vue +11 -0
- package/components/display/empty/empty.css +39 -0
- package/components/display/icon/Icon.astro +52 -0
- package/components/display/icon/Icon.vue +57 -0
- package/components/display/icon/icon.css +47 -0
- package/components/feedback/alert/Alert.astro +52 -0
- package/components/feedback/alert/Alert.vue +60 -0
- package/components/feedback/alert/alert.css +78 -0
- package/components/feedback/progress/Progress.astro +68 -0
- package/components/feedback/progress/Progress.vue +82 -0
- package/components/feedback/progress/progress.css +68 -0
- package/components/feedback/skeleton/Skeleton.astro +32 -0
- package/components/feedback/skeleton/Skeleton.vue +39 -0
- package/components/feedback/skeleton/skeleton.css +56 -0
- package/components/feedback/spinner/Spinner.astro +25 -0
- package/components/feedback/spinner/Spinner.vue +36 -0
- package/components/feedback/spinner/spinner.css +91 -0
- package/components/feedback/toast/Toast.astro +50 -0
- package/components/feedback/toast/Toast.vue +74 -0
- package/components/feedback/toast/toast.css +128 -0
- package/components/form/checkbox/Checkbox.astro +79 -0
- package/components/form/checkbox/Checkbox.vue +95 -0
- package/components/form/checkbox/checkbox.css +59 -0
- package/components/form/combobox/Combobox.vue +508 -0
- package/components/form/combobox/combobox.css +110 -0
- package/components/form/date-input/DateInput.astro +105 -0
- package/components/form/date-input/DateInput.vue +121 -0
- package/components/form/date-input/date-input.css +19 -0
- package/components/form/form/Form.astro +106 -0
- package/components/form/form/Form.vue +181 -0
- package/components/form/form/form.css +46 -0
- package/components/form/input-otp/InputOTP.astro +147 -0
- package/components/form/input-otp/InputOTP.vue +209 -0
- package/components/form/input-otp/input-otp.css +52 -0
- package/components/form/label/Label.astro +13 -0
- package/components/form/label/Label.vue +20 -0
- package/components/form/label/label.css +11 -0
- package/components/form/number-field/NumberField.astro +142 -0
- package/components/form/number-field/NumberField.vue +155 -0
- package/components/form/number-field/number-field.css +115 -0
- package/components/form/radio-group/RadioGroup.astro +105 -0
- package/components/form/radio-group/RadioGroup.vue +110 -0
- package/components/form/radio-group/radio-group.css +114 -0
- package/components/form/radio-group/types.ts +14 -0
- package/components/form/select/Segmented.vue +36 -0
- package/components/form/select/Select.astro +105 -0
- package/components/form/select/Select.vue +109 -0
- package/components/form/select/select.css +96 -0
- package/components/form/slider/Slider.astro +205 -0
- package/components/form/slider/Slider.vue +321 -0
- package/components/form/slider/slider.css +115 -0
- package/components/form/switch/Switch.astro +75 -0
- package/components/form/switch/Switch.vue +89 -0
- package/components/form/switch/switch.css +64 -0
- package/components/form/tags-input/TagsInput.astro +153 -0
- package/components/form/tags-input/TagsInput.vue +207 -0
- package/components/form/tags-input/tags-input.css +128 -0
- package/components/form/text-input/TextInput.astro +84 -0
- package/components/form/text-input/TextInput.vue +99 -0
- package/components/form/text-input/text-input.css +165 -0
- package/components/form/textarea/Textarea.astro +86 -0
- package/components/form/textarea/Textarea.vue +102 -0
- package/components/form/textarea/textarea.css +25 -0
- package/components/layout/accordion/Accordion.vue +59 -0
- package/components/layout/accordion/accordion.css +87 -0
- package/components/layout/card/Card.astro +13 -0
- package/components/layout/card/Card.vue +20 -0
- package/components/layout/card/card.css +55 -0
- package/components/layout/collapsible/Collapsible.vue +77 -0
- package/components/layout/collapsible/collapsible.css +76 -0
- package/components/layout/separator/Separator.astro +31 -0
- package/components/layout/separator/Separator.vue +33 -0
- package/components/layout/separator/separator.css +27 -0
- package/components/layout/table/DataTable.vue +127 -0
- package/components/layout/table/Table.astro +116 -0
- package/components/layout/table/Table.vue +146 -0
- package/components/layout/table/TableRow.vue +59 -0
- package/components/layout/table/table.css +201 -0
- package/components/layout/table/types.ts +35 -0
- package/components/layout/table/useTable.ts +7 -0
- package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
- package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
- package/components/navigation/breadcrumb/breadcrumb.css +37 -0
- package/components/navigation/navbar/Navbar.astro +62 -0
- package/components/navigation/navbar/Navbar.vue +50 -0
- package/components/navigation/navbar/navbar.css +77 -0
- package/components/navigation/pagination/Pagination.vue +107 -0
- package/components/navigation/pagination/pagination.css +53 -0
- package/components/navigation/sidebar/Sidebar.astro +132 -0
- package/components/navigation/sidebar/Sidebar.vue +174 -0
- package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
- package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
- package/components/navigation/sidebar/sidebar.css +303 -0
- package/components/navigation/sidebar/types.ts +72 -0
- package/components/navigation/tabs/Tabs.vue +84 -0
- package/components/navigation/tabs/tabs.css +39 -0
- package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
- package/components/overlay/alert-dialog/alert-dialog.css +57 -0
- package/components/overlay/command/Command.vue +356 -0
- package/components/overlay/command/command.css +179 -0
- package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
- package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
- package/components/overlay/modal/Modal.astro +66 -0
- package/components/overlay/modal/Modal.vue +85 -0
- package/components/overlay/modal/modal.css +60 -0
- package/components/overlay/popover/Popover.vue +113 -0
- package/components/overlay/popover/popover.css +53 -0
- package/components/overlay/sheet/Sheet.vue +88 -0
- package/components/overlay/sheet/sheet.css +108 -0
- package/components/overlay/tooltip/Tooltip.vue +210 -0
- package/components/overlay/tooltip/tooltip.css +50 -0
- package/composables/useUrlSort.ts +48 -0
- package/icons/alert-triangle.ts +1 -0
- package/icons/arrow-down.ts +1 -0
- package/icons/arrow-up-down.ts +5 -0
- package/icons/arrow-up.ts +1 -0
- package/icons/bell.ts +1 -0
- package/icons/check.ts +1 -0
- package/icons/chevron-down.ts +1 -0
- package/icons/chevron-left.ts +1 -0
- package/icons/chevron-right.ts +1 -0
- package/icons/chevron-up-down.ts +5 -0
- package/icons/chevron-up.ts +1 -0
- package/icons/circle-alert.ts +1 -0
- package/icons/circle-check.ts +1 -0
- package/icons/clipboard.ts +1 -0
- package/icons/download.ts +1 -0
- package/icons/edit.ts +1 -0
- package/icons/external-link.ts +1 -0
- package/icons/eye.ts +1 -0
- package/icons/file.ts +1 -0
- package/icons/filter.ts +1 -0
- package/icons/folder.ts +1 -0
- package/icons/image.ts +1 -0
- package/icons/inbox.ts +1 -0
- package/icons/index.ts +91 -0
- package/icons/info.ts +1 -0
- package/icons/layers.ts +1 -0
- package/icons/link-2.ts +1 -0
- package/icons/list.ts +1 -0
- package/icons/loader.ts +3 -0
- package/icons/menu.ts +1 -0
- package/icons/more-horizontal.ts +1 -0
- package/icons/more-vertical.ts +1 -0
- package/icons/plus-circle.ts +1 -0
- package/icons/plus.ts +1 -0
- package/icons/save.ts +1 -0
- package/icons/search.ts +1 -0
- package/icons/send.ts +1 -0
- package/icons/settings.ts +1 -0
- package/icons/tool.ts +1 -0
- package/icons/trash-2.ts +1 -0
- package/icons/trash.ts +1 -0
- package/icons/upload-cloud.ts +1 -0
- package/icons/upload.ts +1 -0
- package/icons/x.ts +1 -0
- package/package.json +150 -0
- package/styles/0-settings/colors.css +241 -0
- package/styles/0-settings/index.css +5 -0
- package/styles/0-settings/layout.css +52 -0
- package/styles/0-settings/motion.css +11 -0
- package/styles/0-settings/spacing.css +15 -0
- package/styles/0-settings/typography.css +37 -0
- package/styles/0-utils/index.css +1 -0
- package/styles/1-reset/index.css +1 -0
- package/styles/1-reset/reset.css +26 -0
- package/styles/2-base/base.css +42 -0
- package/styles/2-base/forms.css +23 -0
- package/styles/2-base/index.css +2 -0
- package/styles/3-layout/container.css +57 -0
- package/styles/3-layout/index.css +2 -0
- package/styles/3-layout/section.css +17 -0
- package/styles/5-utilities/accessibility.css +13 -0
- package/styles/5-utilities/index.css +2 -0
- package/styles/5-utilities/text.css +5 -0
- package/styles/main.css +8 -0
- package/styles/styles.d.ts +6 -0
- package/utils/a11y/focus.ts +68 -0
- package/utils/a11y/id.ts +10 -0
- package/utils/a11y/index.ts +9 -0
- package/utils/a11y/keyboard.ts +32 -0
- package/utils/a11y/live-region.ts +36 -0
- package/utils/controllers/dialog.ts +205 -0
- package/utils/controllers/disclosure.ts +117 -0
- package/utils/controllers/form.ts +524 -0
- package/utils/controllers/index.ts +39 -0
- package/utils/controllers/menu.ts +255 -0
- package/utils/controllers/number-field.ts +103 -0
- package/utils/controllers/otp.ts +252 -0
- package/utils/controllers/popover.ts +434 -0
- package/utils/controllers/sidebar.ts +610 -0
- package/utils/controllers/slider.ts +336 -0
- package/utils/controllers/tags-input.ts +255 -0
- package/utils/controllers/toast.ts +426 -0
- package/utils/dom/index.ts +1 -0
- package/utils/dom/scroll-lock.ts +48 -0
- package/utils/index.ts +3 -0
- package/utils/sort/index.ts +3 -0
- package/utils/sort/serialize.ts +19 -0
- package/utils/sort/state.ts +11 -0
- package/utils/sort/types.ts +15 -0
- package/utils/validation/form.ts +93 -0
- package/utils/validation/index.ts +13 -0
- package/utils/validation/rules.ts +31 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
+
<label class="pui-field__label" :for="inputId">{{ label }}</label>
|
|
4
|
+
<div class="pui-number-field">
|
|
5
|
+
<input
|
|
6
|
+
v-bind="$attrs"
|
|
7
|
+
:id="inputId"
|
|
8
|
+
ref="inputRef"
|
|
9
|
+
class="pui-number-field__input pui-input"
|
|
10
|
+
type="number"
|
|
11
|
+
:name="name"
|
|
12
|
+
:min="min"
|
|
13
|
+
:max="max"
|
|
14
|
+
:step="step"
|
|
15
|
+
:placeholder="placeholder"
|
|
16
|
+
:required="required"
|
|
17
|
+
:disabled="disabled"
|
|
18
|
+
:value="modelValue"
|
|
19
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
20
|
+
:aria-describedby="describedBy"
|
|
21
|
+
@input="onInput"
|
|
22
|
+
@blur="emit('blur')"
|
|
23
|
+
/>
|
|
24
|
+
<div class="pui-number-field__steppers">
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
class="pui-number-field__step"
|
|
28
|
+
data-direction="increment"
|
|
29
|
+
aria-label="Increase"
|
|
30
|
+
:disabled="disabled"
|
|
31
|
+
@click="handleStep('increment')"
|
|
32
|
+
>
|
|
33
|
+
<span aria-hidden="true">+</span>
|
|
34
|
+
</button>
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
class="pui-number-field__step"
|
|
38
|
+
data-direction="decrement"
|
|
39
|
+
aria-label="Decrease"
|
|
40
|
+
:disabled="disabled"
|
|
41
|
+
@click="handleStep('decrement')"
|
|
42
|
+
>
|
|
43
|
+
<span aria-hidden="true">−</span>
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
|
|
48
|
+
<ul
|
|
49
|
+
v-if="errors.length"
|
|
50
|
+
:id="errorsId"
|
|
51
|
+
class="pui-field__hint"
|
|
52
|
+
role="alert"
|
|
53
|
+
>
|
|
54
|
+
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
55
|
+
</ul>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
import { computed, ref } from 'vue';
|
|
61
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
62
|
+
|
|
63
|
+
defineOptions({ inheritAttrs: false });
|
|
64
|
+
|
|
65
|
+
const props = withDefaults(
|
|
66
|
+
defineProps<{
|
|
67
|
+
id?: string;
|
|
68
|
+
label: string;
|
|
69
|
+
/**
|
|
70
|
+
* Current numeric value. `undefined` represents an empty input;
|
|
71
|
+
* `NaN` is also tolerated (the native input renders empty when
|
|
72
|
+
* its value is not a finite number).
|
|
73
|
+
*/
|
|
74
|
+
modelValue?: number;
|
|
75
|
+
name?: string;
|
|
76
|
+
min?: number;
|
|
77
|
+
max?: number;
|
|
78
|
+
step?: number | string;
|
|
79
|
+
placeholder?: string;
|
|
80
|
+
required?: boolean;
|
|
81
|
+
disabled?: boolean;
|
|
82
|
+
hint?: string;
|
|
83
|
+
errors?: string[];
|
|
84
|
+
status?: 'error' | 'success';
|
|
85
|
+
}>(),
|
|
86
|
+
{
|
|
87
|
+
id: undefined,
|
|
88
|
+
modelValue: undefined,
|
|
89
|
+
name: undefined,
|
|
90
|
+
min: undefined,
|
|
91
|
+
max: undefined,
|
|
92
|
+
step: undefined,
|
|
93
|
+
placeholder: undefined,
|
|
94
|
+
required: false,
|
|
95
|
+
disabled: false,
|
|
96
|
+
hint: undefined,
|
|
97
|
+
errors: () => [],
|
|
98
|
+
status: undefined,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const emit = defineEmits<{
|
|
103
|
+
'update:modelValue': [value: number];
|
|
104
|
+
blur: [];
|
|
105
|
+
}>();
|
|
106
|
+
|
|
107
|
+
const inputRef = ref<HTMLInputElement | null>(null);
|
|
108
|
+
|
|
109
|
+
const inputId = props.id ?? generateId('input');
|
|
110
|
+
const hintId = `${inputId}-hint`;
|
|
111
|
+
const errorsId = `${inputId}-errors`;
|
|
112
|
+
|
|
113
|
+
const hasErrors = computed(() => props.errors.length > 0);
|
|
114
|
+
|
|
115
|
+
const computedStatus = computed(() => {
|
|
116
|
+
if (props.status) return props.status;
|
|
117
|
+
if (hasErrors.value) return 'error';
|
|
118
|
+
return undefined;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const describedBy = computed(() => {
|
|
122
|
+
const ids: string[] = [];
|
|
123
|
+
if (props.hint) ids.push(hintId);
|
|
124
|
+
if (hasErrors.value) ids.push(errorsId);
|
|
125
|
+
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
function onInput(event: Event): void {
|
|
129
|
+
const target = event.target as HTMLInputElement;
|
|
130
|
+
// valueAsNumber is NaN when the input is empty or otherwise not a
|
|
131
|
+
// finite number. We pass it through unchanged so consumers can
|
|
132
|
+
// distinguish "cleared field" from "explicit 0".
|
|
133
|
+
emit('update:modelValue', target.valueAsNumber);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function handleStep(direction: 'decrement' | 'increment'): void {
|
|
137
|
+
if (props.disabled) return;
|
|
138
|
+
const input = inputRef.value;
|
|
139
|
+
if (!input) return;
|
|
140
|
+
if (direction === 'increment') {
|
|
141
|
+
input.stepUp();
|
|
142
|
+
} else {
|
|
143
|
+
input.stepDown();
|
|
144
|
+
}
|
|
145
|
+
// Dispatch native input/change events so consumer-attached DOM
|
|
146
|
+
// listeners observe the same behavior as the Astro controller path.
|
|
147
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
148
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
149
|
+
emit('update:modelValue', input.valueAsNumber);
|
|
150
|
+
}
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<style>
|
|
154
|
+
@import './number-field.css';
|
|
155
|
+
</style>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
/* ===== pui-number-field (input + flanking step buttons) =====
|
|
3
|
+
*
|
|
4
|
+
* NumberField extends the locked Form-primitive scaffold (the outer
|
|
5
|
+
* `.pui-field` wrapper from `text-input.css`) with an inline-flex
|
|
6
|
+
* row that hosts the native `<input type="number">` on the left and
|
|
7
|
+
* two stacked step buttons on the right. The native browser spinner
|
|
8
|
+
* is suppressed via `appearance: textfield` (Webkit / Firefox)
|
|
9
|
+
* because we provide our own larger, focus-visible step buttons —
|
|
10
|
+
* the locked layout choice per the NumberField audit.
|
|
11
|
+
*
|
|
12
|
+
* Layout rationale: input + stacked step buttons (increment-top /
|
|
13
|
+
* decrement-bottom) matches the Reka UI reference and the visual
|
|
14
|
+
* intuition of "up to grow, down to shrink". The buttons share the
|
|
15
|
+
* input's border so they read as one integrated control while
|
|
16
|
+
* keeping each segment independently focusable for keyboard users.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
.pui-number-field {
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
align-items: stretch;
|
|
22
|
+
width: 100%;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.pui-number-field__input {
|
|
26
|
+
/* Inherits sizing, padding, focus-ring, disabled, and status
|
|
27
|
+
* recolour from the shared .pui-input rule (text-input.css). The
|
|
28
|
+
* overrides here only adjust the right-side border radii so the
|
|
29
|
+
* input segment butts cleanly against the stepper column. */
|
|
30
|
+
flex: 1;
|
|
31
|
+
border-top-right-radius: 0;
|
|
32
|
+
border-bottom-right-radius: 0;
|
|
33
|
+
/* Suppress the native spinner — we provide our own buttons. */
|
|
34
|
+
appearance: textfield;
|
|
35
|
+
-moz-appearance: textfield;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.pui-number-field__input::-webkit-inner-spin-button,
|
|
39
|
+
.pui-number-field__input::-webkit-outer-spin-button {
|
|
40
|
+
appearance: none;
|
|
41
|
+
-webkit-appearance: none;
|
|
42
|
+
margin: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Stacked column for the +/- buttons. Sits flush against the
|
|
46
|
+
* input's right edge; each button takes half the column height. */
|
|
47
|
+
.pui-number-field__steppers {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.pui-number-field__step {
|
|
53
|
+
appearance: none;
|
|
54
|
+
background: var(--bg-clr-surface);
|
|
55
|
+
color: var(--text-clr-base);
|
|
56
|
+
border: var(--stroke-sm) solid var(--border-clr-base);
|
|
57
|
+
/* Avoid double border between the input and the stepper column
|
|
58
|
+
* (the input already paints the shared edge). */
|
|
59
|
+
border-left: 0;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
flex: 1;
|
|
62
|
+
font: inherit;
|
|
63
|
+
font-weight: var(--fw-bold);
|
|
64
|
+
line-height: 1;
|
|
65
|
+
padding: 0 var(--space-2xs);
|
|
66
|
+
display: grid;
|
|
67
|
+
place-items: center;
|
|
68
|
+
/* Ensure the stacked buttons fit the input height even when the
|
|
69
|
+
* font happens to be larger than the input's line-box. */
|
|
70
|
+
min-width: 1.75rem;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.pui-number-field__step[data-direction='increment'] {
|
|
74
|
+
border-top-right-radius: var(--radius-sm);
|
|
75
|
+
/* Avoid double border between the two stacked buttons. */
|
|
76
|
+
border-bottom: 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.pui-number-field__step[data-direction='decrement'] {
|
|
80
|
+
border-bottom-right-radius: var(--radius-sm);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.pui-number-field__step:hover:not(:disabled) {
|
|
84
|
+
background: var(--bg-clr-surface-2);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.pui-number-field__step:focus-visible {
|
|
88
|
+
outline: 3px solid var(--outline-clr-base);
|
|
89
|
+
outline-offset: 2px;
|
|
90
|
+
/* Lift the focused button above its neighbours so the outline
|
|
91
|
+
* isn't clipped by the adjacent button's edge. */
|
|
92
|
+
position: relative;
|
|
93
|
+
z-index: 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.pui-number-field__step:disabled {
|
|
97
|
+
background: var(--bg-clr-surface-2);
|
|
98
|
+
color: var(--text-clr-muted);
|
|
99
|
+
cursor: not-allowed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Status states inherited from .pui-field — recolour BOTH the input
|
|
103
|
+
* and the step buttons so the whole control reads as errored /
|
|
104
|
+
* succeeded. Mirrors the .pui-input / .pui-textarea status recolour in
|
|
105
|
+
* text-input.css. */
|
|
106
|
+
.pui-field[data-status='error'] .pui-number-field__input,
|
|
107
|
+
.pui-field[data-status='error'] .pui-number-field__step {
|
|
108
|
+
border-color: var(--border-clr-danger);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.pui-field[data-status='success'] .pui-number-field__input,
|
|
112
|
+
.pui-field[data-status='success'] .pui-number-field__step {
|
|
113
|
+
border-color: var(--border-clr-success);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './radio-group.css';
|
|
3
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
4
|
+
import type { RadioOption } from './types.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
id?: string;
|
|
8
|
+
label: string;
|
|
9
|
+
name: string;
|
|
10
|
+
options: RadioOption[];
|
|
11
|
+
value?: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
orientation?: 'vertical' | 'horizontal';
|
|
14
|
+
hint?: string;
|
|
15
|
+
errors?: string[];
|
|
16
|
+
status?: 'error' | 'success';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
id,
|
|
21
|
+
label,
|
|
22
|
+
name,
|
|
23
|
+
options,
|
|
24
|
+
value,
|
|
25
|
+
disabled = false,
|
|
26
|
+
orientation = 'vertical',
|
|
27
|
+
hint,
|
|
28
|
+
errors = [],
|
|
29
|
+
status,
|
|
30
|
+
...rest
|
|
31
|
+
} = Astro.props;
|
|
32
|
+
|
|
33
|
+
const fieldId = id ?? generateId('radiogroup');
|
|
34
|
+
const hintId = `${fieldId}-hint`;
|
|
35
|
+
const errorsId = `${fieldId}-errors`;
|
|
36
|
+
|
|
37
|
+
const hasErrors = errors.length > 0;
|
|
38
|
+
const computedStatus = status ?? (hasErrors ? 'error' : undefined);
|
|
39
|
+
|
|
40
|
+
const describedByIds: string[] = [];
|
|
41
|
+
if (hint) describedByIds.push(hintId);
|
|
42
|
+
if (hasErrors) describedByIds.push(errorsId);
|
|
43
|
+
const describedBy =
|
|
44
|
+
describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<fieldset
|
|
48
|
+
id={fieldId}
|
|
49
|
+
class="pui-field"
|
|
50
|
+
data-status={computedStatus}
|
|
51
|
+
role="radiogroup"
|
|
52
|
+
aria-describedby={describedBy}
|
|
53
|
+
aria-invalid={hasErrors ? 'true' : undefined}
|
|
54
|
+
{...rest}
|
|
55
|
+
>
|
|
56
|
+
{
|
|
57
|
+
/* aria-invalid lives on the <fieldset> (group-level signal) AND, when the
|
|
58
|
+
Astro mountForm controller is wired, on the first radio. The redundancy
|
|
59
|
+
is intentional — see CONVENTIONS.md "RadioGroup audit → ARIA
|
|
60
|
+
expectations" for the rationale. Do NOT remove either binding without
|
|
61
|
+
coordinating with the form controller. */
|
|
62
|
+
}
|
|
63
|
+
<legend class="pui-field__label">{label}</legend>
|
|
64
|
+
{
|
|
65
|
+
hint && (
|
|
66
|
+
<p id={hintId} class="pui-field__hint">
|
|
67
|
+
{hint}
|
|
68
|
+
</p>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
<div class="pui-radio-group" data-orientation={orientation}>
|
|
72
|
+
{
|
|
73
|
+
options.map((option) => {
|
|
74
|
+
const isChecked = value === option.value;
|
|
75
|
+
const isDisabled = option.disabled || disabled;
|
|
76
|
+
return (
|
|
77
|
+
<label
|
|
78
|
+
class="pui-radio-group__item"
|
|
79
|
+
data-disabled={isDisabled ? 'true' : undefined}
|
|
80
|
+
>
|
|
81
|
+
<input
|
|
82
|
+
class="pui-radio"
|
|
83
|
+
type="radio"
|
|
84
|
+
name={name}
|
|
85
|
+
value={option.value}
|
|
86
|
+
checked={isChecked}
|
|
87
|
+
disabled={isDisabled}
|
|
88
|
+
aria-checked={isChecked ? 'true' : 'false'}
|
|
89
|
+
/>
|
|
90
|
+
<span class="pui-radio-group__label">{option.label}</span>
|
|
91
|
+
</label>
|
|
92
|
+
);
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
</div>
|
|
96
|
+
{
|
|
97
|
+
hasErrors && (
|
|
98
|
+
<ul id={errorsId} class="pui-field__hint" role="alert">
|
|
99
|
+
{errors.map((error) => (
|
|
100
|
+
<li>{error}</li>
|
|
101
|
+
))}
|
|
102
|
+
</ul>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
</fieldset>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<fieldset
|
|
3
|
+
v-bind="$attrs"
|
|
4
|
+
:id="fieldId"
|
|
5
|
+
class="pui-field"
|
|
6
|
+
:data-status="computedStatus"
|
|
7
|
+
role="radiogroup"
|
|
8
|
+
:aria-describedby="describedBy"
|
|
9
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
10
|
+
>
|
|
11
|
+
<!-- aria-invalid lives on the <fieldset> (group-level signal) AND, when the
|
|
12
|
+
Astro mountForm controller is wired, on the first radio. The redundancy
|
|
13
|
+
is intentional — see ./AUDIT.md "ARIA
|
|
14
|
+
expectations" for the rationale. Do NOT remove either binding without
|
|
15
|
+
coordinating with the form controller. -->
|
|
16
|
+
<legend class="pui-field__label">{{ label }}</legend>
|
|
17
|
+
<p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
|
|
18
|
+
<div class="pui-radio-group" :data-orientation="orientation">
|
|
19
|
+
<label
|
|
20
|
+
v-for="option in options"
|
|
21
|
+
:key="option.value"
|
|
22
|
+
class="pui-radio-group__item"
|
|
23
|
+
:data-disabled="
|
|
24
|
+
option.disabled || disabled ? 'true' : undefined
|
|
25
|
+
"
|
|
26
|
+
>
|
|
27
|
+
<input
|
|
28
|
+
class="pui-radio"
|
|
29
|
+
type="radio"
|
|
30
|
+
:name="name"
|
|
31
|
+
:value="option.value"
|
|
32
|
+
:checked="modelValue === option.value"
|
|
33
|
+
:disabled="option.disabled || disabled"
|
|
34
|
+
:aria-checked="
|
|
35
|
+
modelValue === option.value ? 'true' : 'false'
|
|
36
|
+
"
|
|
37
|
+
@change="emit('update:modelValue', option.value)"
|
|
38
|
+
/>
|
|
39
|
+
<span class="pui-radio-group__label">{{ option.label }}</span>
|
|
40
|
+
</label>
|
|
41
|
+
</div>
|
|
42
|
+
<ul
|
|
43
|
+
v-if="errors.length"
|
|
44
|
+
:id="errorsId"
|
|
45
|
+
class="pui-field__hint"
|
|
46
|
+
role="alert"
|
|
47
|
+
>
|
|
48
|
+
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
49
|
+
</ul>
|
|
50
|
+
</fieldset>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<script setup lang="ts">
|
|
54
|
+
import { computed } from 'vue';
|
|
55
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
56
|
+
import type { RadioOption } from './types.js';
|
|
57
|
+
|
|
58
|
+
defineOptions({ inheritAttrs: false });
|
|
59
|
+
|
|
60
|
+
const props = withDefaults(
|
|
61
|
+
defineProps<{
|
|
62
|
+
id?: string;
|
|
63
|
+
label: string;
|
|
64
|
+
name: string;
|
|
65
|
+
options: RadioOption[];
|
|
66
|
+
modelValue?: string;
|
|
67
|
+
disabled?: boolean;
|
|
68
|
+
orientation?: 'vertical' | 'horizontal';
|
|
69
|
+
hint?: string;
|
|
70
|
+
errors?: string[];
|
|
71
|
+
status?: 'error' | 'success';
|
|
72
|
+
}>(),
|
|
73
|
+
{
|
|
74
|
+
id: undefined,
|
|
75
|
+
modelValue: undefined,
|
|
76
|
+
disabled: false,
|
|
77
|
+
orientation: 'vertical',
|
|
78
|
+
hint: undefined,
|
|
79
|
+
errors: () => [],
|
|
80
|
+
status: undefined,
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const emit = defineEmits<{
|
|
85
|
+
'update:modelValue': [value: string];
|
|
86
|
+
}>();
|
|
87
|
+
|
|
88
|
+
const fieldId = props.id ?? generateId('radiogroup');
|
|
89
|
+
const hintId = `${fieldId}-hint`;
|
|
90
|
+
const errorsId = `${fieldId}-errors`;
|
|
91
|
+
|
|
92
|
+
const hasErrors = computed(() => props.errors.length > 0);
|
|
93
|
+
|
|
94
|
+
const computedStatus = computed(() => {
|
|
95
|
+
if (props.status) return props.status;
|
|
96
|
+
if (hasErrors.value) return 'error';
|
|
97
|
+
return undefined;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const describedBy = computed(() => {
|
|
101
|
+
const ids: string[] = [];
|
|
102
|
+
if (props.hint) ids.push(hintId);
|
|
103
|
+
if (hasErrors.value) ids.push(errorsId);
|
|
104
|
+
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
105
|
+
});
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<style>
|
|
109
|
+
@import './radio-group.css';
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
/* ===== pui-radio-group (group container with orientation) =====
|
|
3
|
+
*
|
|
4
|
+
* Hosted inside a `<fieldset class="pui-field">` form-primitive scaffold —
|
|
5
|
+
* legend (label) above, hint after legend, items below, errors below items.
|
|
6
|
+
* The group container itself only owns layout of the item list; the field
|
|
7
|
+
* chrome (label / hint / errors / data-status recolour) lives on the
|
|
8
|
+
* surrounding pui-field per the locked form-primitive wrapper archetype.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
.pui-radio-group {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
gap: var(--space-xs);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.pui-radio-group[data-orientation='horizontal'] {
|
|
18
|
+
flex-direction: row;
|
|
19
|
+
flex-wrap: wrap;
|
|
20
|
+
column-gap: var(--space-s);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Each option is a label-wrapping-input pattern: clicking anywhere on the
|
|
24
|
+
* label area selects the radio. Different from Switch / Checkbox /
|
|
25
|
+
* TextInput which use <label for> association — radio item labels are
|
|
26
|
+
* short string content from the option's `label`, label-wrapping is more
|
|
27
|
+
* compact and well-established for radio lists. */
|
|
28
|
+
.pui-radio-group__item {
|
|
29
|
+
display: inline-flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: var(--space-2xs);
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
font-size: inherit;
|
|
34
|
+
/* Override base/forms.css `label { font-weight: var(--fw-semibold) }`:
|
|
35
|
+
* radio-item labels are body text, not field labels, and inherit the
|
|
36
|
+
* fieldset's <legend> as the actual field label. */
|
|
37
|
+
font-weight: var(--fw-regular);
|
|
38
|
+
/* Override base/forms.css `label { margin-bottom: var(--space-xs) }`:
|
|
39
|
+
* the surrounding `.pui-radio-group` flex `gap` already governs item
|
|
40
|
+
* spacing; an inherited margin-bottom would compound and split the gap. */
|
|
41
|
+
margin-bottom: 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.pui-radio-group__item[data-disabled='true'] {
|
|
45
|
+
color: var(--text-clr-muted);
|
|
46
|
+
cursor: not-allowed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.pui-radio-group__label {
|
|
50
|
+
/* `line-height: 1` clipped descenders on long labels that wrapped; 1.2
|
|
51
|
+
* gives breathing room without over-spacing the inline-flex item. */
|
|
52
|
+
line-height: 1.2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ===== pui-radio (individual native input) =====
|
|
56
|
+
*
|
|
57
|
+
* Round, custom-styled native <input type="radio"> with a centered inner
|
|
58
|
+
* dot when checked. Native radios with a shared `name` give us arrow-key
|
|
59
|
+
* navigation, only-one-selected, and Tab-focuses-selected for free; no
|
|
60
|
+
* controller required (per the WAI-ARIA APG radiogroup notes — Radix
|
|
61
|
+
* needs roving-tabindex because they use role="radio" on <div> elements;
|
|
62
|
+
* we use the native input).
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
.pui-radio {
|
|
66
|
+
appearance: none;
|
|
67
|
+
flex: none;
|
|
68
|
+
margin: 0;
|
|
69
|
+
width: 1.15em;
|
|
70
|
+
height: 1.15em;
|
|
71
|
+
border: var(--stroke-md) solid var(--border-clr-strong);
|
|
72
|
+
border-radius: 50%;
|
|
73
|
+
background: var(--bg-clr-surface);
|
|
74
|
+
display: grid;
|
|
75
|
+
place-items: center;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
transition:
|
|
78
|
+
background 0.12s ease,
|
|
79
|
+
border-color 0.12s ease;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.pui-radio:checked {
|
|
83
|
+
background: var(--bg-clr-brand);
|
|
84
|
+
border-color: var(--border-clr-brand);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.pui-radio:checked::after {
|
|
88
|
+
content: '';
|
|
89
|
+
width: 0.45em;
|
|
90
|
+
height: 0.45em;
|
|
91
|
+
border-radius: 50%;
|
|
92
|
+
background: var(--bg-clr-brand-ink);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.pui-radio:focus-visible {
|
|
96
|
+
outline: 3px solid var(--outline-clr-base);
|
|
97
|
+
outline-offset: 2px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.pui-radio:disabled {
|
|
101
|
+
opacity: 0.5;
|
|
102
|
+
cursor: not-allowed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* Status-driven recolour from the parent pui-field wrapper. */
|
|
106
|
+
.pui-field[data-status='error'] .pui-radio {
|
|
107
|
+
border-color: var(--border-clr-danger);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.pui-field[data-status='success'] .pui-radio:checked {
|
|
111
|
+
background: var(--bg-clr-success, var(--bg-clr-brand));
|
|
112
|
+
border-color: var(--bg-clr-success, var(--border-clr-brand));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single radio option entry in a {@link RadioGroup}'s `:options` prop.
|
|
3
|
+
*
|
|
4
|
+
* The flat single-component RadioGroup (per design D2 — flat API) takes a
|
|
5
|
+
* typed array of these instead of compound `<RadioGroupItem>` subcomponents.
|
|
6
|
+
*/
|
|
7
|
+
export type RadioOption = {
|
|
8
|
+
/** Form value emitted when this option is selected. */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Display label rendered next to the radio. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Disable just this option. Group-level disable is out of scope for v1. */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="pui-segmented" role="group" :aria-label="label">
|
|
3
|
+
<button
|
|
4
|
+
v-for="opt in options"
|
|
5
|
+
:key="opt.value"
|
|
6
|
+
class="pui-segmented__item"
|
|
7
|
+
type="button"
|
|
8
|
+
:aria-pressed="modelValue === opt.value ? 'true' : 'false'"
|
|
9
|
+
@click="emit('update:modelValue', opt.value)"
|
|
10
|
+
>
|
|
11
|
+
{{ opt.label }}
|
|
12
|
+
</button>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
withDefaults(
|
|
18
|
+
defineProps<{
|
|
19
|
+
modelValue?: string;
|
|
20
|
+
options: { value: string; label: string }[];
|
|
21
|
+
label?: string;
|
|
22
|
+
}>(),
|
|
23
|
+
{
|
|
24
|
+
modelValue: undefined,
|
|
25
|
+
label: undefined,
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
'update:modelValue': [value: string];
|
|
31
|
+
}>();
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
@import './select.css';
|
|
36
|
+
</style>
|