@pienter/ui 0.2.0 → 0.5.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/CHANGELOG.md +259 -0
  2. package/CONVENTIONS.md +297 -385
  3. package/README.md +3 -18
  4. package/components/feedback/toast/toast.css +1 -1
  5. package/components/form/combobox/Combobox.vue +21 -38
  6. package/components/form/combobox/combobox.css +1 -1
  7. package/components/form/form/Form.vue +1 -2
  8. package/components/form/label/label.css +1 -1
  9. package/components/form/number-field/NumberField.vue +1 -1
  10. package/components/form/number-field/number-field.css +1 -1
  11. package/components/form/radio-group/RadioGroup.vue +2 -5
  12. package/components/form/slider/slider.css +2 -3
  13. package/components/form/tags-input/tags-input.css +1 -2
  14. package/components/form/textarea/textarea.css +1 -1
  15. package/components/layout/accordion/Accordion.vue +6 -27
  16. package/components/layout/collapsible/Collapsible.vue +9 -19
  17. package/components/layout/table/table.css +3 -3
  18. package/components/navigation/pagination/Pagination.vue +1 -1
  19. package/components/navigation/pagination/PaginationFooter.vue +23 -0
  20. package/components/navigation/pagination/pagination.css +128 -29
  21. package/components/navigation/sidebar/Sidebar.vue +326 -84
  22. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  23. package/components/navigation/sidebar/context.ts +21 -0
  24. package/components/navigation/sidebar/sidebar.css +346 -78
  25. package/components/navigation/sidebar/types.ts +13 -1
  26. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  27. package/components/overlay/command/Command.vue +10 -38
  28. package/components/overlay/command/command.css +1 -1
  29. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  30. package/components/overlay/modal/Modal.vue +7 -28
  31. package/components/overlay/popover/Popover.vue +9 -31
  32. package/components/overlay/sheet/Sheet.vue +7 -28
  33. package/components/overlay/tooltip/Tooltip.vue +14 -19
  34. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  35. package/composables/useDisclosure.ts +113 -0
  36. package/composables/useEventListener.ts +16 -0
  37. package/{utils/controllers/menu.ts → composables/useMenu.ts} +66 -102
  38. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  39. package/package.json +6 -38
  40. package/styles/0-settings/colors.css +8 -1
  41. package/styles/0-settings/layout.css +18 -0
  42. package/styles/0-settings/motion.css +2 -2
  43. package/styles/0-settings/spacing.css +3 -1
  44. package/utils/navigation/sidebar.ts +97 -0
  45. package/utils/validation/form.ts +6 -9
  46. package/components/action/button/Button.astro +0 -95
  47. package/components/action/button/IconButton.astro +0 -86
  48. package/components/display/avatar/Avatar.astro +0 -17
  49. package/components/display/avatar/AvatarStack.astro +0 -9
  50. package/components/display/badge/Badge.astro +0 -15
  51. package/components/display/empty/Empty.astro +0 -9
  52. package/components/display/icon/Icon.astro +0 -52
  53. package/components/feedback/alert/Alert.astro +0 -52
  54. package/components/feedback/progress/Progress.astro +0 -68
  55. package/components/feedback/skeleton/Skeleton.astro +0 -32
  56. package/components/feedback/spinner/Spinner.astro +0 -25
  57. package/components/feedback/toast/Toast.astro +0 -50
  58. package/components/form/checkbox/Checkbox.astro +0 -79
  59. package/components/form/date-input/DateInput.astro +0 -105
  60. package/components/form/form/Form.astro +0 -106
  61. package/components/form/input-otp/InputOTP.astro +0 -147
  62. package/components/form/label/Label.astro +0 -13
  63. package/components/form/number-field/NumberField.astro +0 -142
  64. package/components/form/radio-group/RadioGroup.astro +0 -105
  65. package/components/form/select/Select.astro +0 -105
  66. package/components/form/slider/Slider.astro +0 -205
  67. package/components/form/switch/Switch.astro +0 -75
  68. package/components/form/tags-input/TagsInput.astro +0 -153
  69. package/components/form/text-input/TextInput.astro +0 -84
  70. package/components/form/textarea/Textarea.astro +0 -86
  71. package/components/layout/card/Card.astro +0 -13
  72. package/components/layout/separator/Separator.astro +0 -31
  73. package/components/layout/table/Table.astro +0 -116
  74. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  75. package/components/navigation/navbar/Navbar.astro +0 -62
  76. package/components/navigation/sidebar/Sidebar.astro +0 -132
  77. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  78. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  79. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  80. package/components/overlay/modal/Modal.astro +0 -66
  81. package/utils/controllers/disclosure.ts +0 -117
  82. package/utils/controllers/form.ts +0 -524
  83. package/utils/controllers/index.ts +0 -39
  84. package/utils/controllers/number-field.ts +0 -103
  85. package/utils/controllers/otp.ts +0 -252
  86. package/utils/controllers/sidebar.ts +0 -610
  87. package/utils/controllers/slider.ts +0 -336
  88. package/utils/controllers/tags-input.ts +0 -255
  89. package/utils/controllers/toast.ts +0 -426
@@ -1,79 +0,0 @@
1
- ---
2
- import './checkbox.css';
3
- import { generateId } from '../../../utils/a11y/id.js';
4
-
5
- interface Props {
6
- id?: string;
7
- label: string;
8
- name?: string;
9
- checked?: boolean;
10
- required?: boolean;
11
- disabled?: boolean;
12
- hint?: string;
13
- errors?: string[];
14
- status?: 'error' | 'success';
15
- }
16
-
17
- const {
18
- id,
19
- label,
20
- name,
21
- checked = false,
22
- required = false,
23
- disabled = false,
24
- hint,
25
- errors = [],
26
- status,
27
- ...rest
28
- } = Astro.props;
29
-
30
- const inputId = id ?? generateId('checkbox');
31
- const hintId = `${inputId}-hint`;
32
- const errorsId = `${inputId}-errors`;
33
-
34
- const hasErrors = errors.length > 0;
35
- const computedStatus = status ?? (hasErrors ? 'error' : undefined);
36
-
37
- const describedByIds: string[] = [];
38
- if (hint) describedByIds.push(hintId);
39
- if (hasErrors) describedByIds.push(errorsId);
40
- const describedBy =
41
- describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
42
-
43
- const ariaChecked = checked ? 'true' : 'false';
44
- ---
45
-
46
- <div class="pui-field" data-layout="inline" data-status={computedStatus}>
47
- <input
48
- class="pui-checkbox"
49
- type="checkbox"
50
- id={inputId}
51
- name={name}
52
- checked={checked}
53
- required={required}
54
- disabled={disabled}
55
- aria-checked={ariaChecked}
56
- aria-invalid={hasErrors ? 'true' : undefined}
57
- aria-describedby={describedBy}
58
- {...rest}
59
- />
60
- <label class="pui-field__label" for={inputId}>
61
- {label}
62
- </label>
63
- {
64
- hint && (
65
- <p id={hintId} class="pui-field__hint">
66
- {hint}
67
- </p>
68
- )
69
- }
70
- {
71
- hasErrors && (
72
- <ul id={errorsId} class="pui-field__hint" role="alert">
73
- {errors.map((error) => (
74
- <li>{error}</li>
75
- ))}
76
- </ul>
77
- )
78
- }
79
- </div>
@@ -1,105 +0,0 @@
1
- ---
2
- import './date-input.css';
3
- import { generateId } from '../../../utils/a11y/id.js';
4
-
5
- interface Props {
6
- id?: string;
7
- label: string;
8
- name?: string;
9
- /**
10
- * Native input type. `month` and `week` are intentionally out of
11
- * scope for v1 because their browser support is uneven (notably
12
- * Safari) — see CONVENTIONS DateInput audit entry.
13
- */
14
- type?: 'date' | 'time' | 'datetime-local';
15
- /**
16
- * Current ISO-formatted value. The exact shape depends on `type`:
17
- * - `date` → `YYYY-MM-DD`
18
- * - `time` → `HH:MM` (or `HH:MM:SS` when `step` enables seconds)
19
- * - `datetime-local` → `YYYY-MM-DDTHH:MM`
20
- */
21
- value?: string;
22
- /** Lower bound, in the same ISO shape as `value`. Forwarded to native `min`. */
23
- min?: string;
24
- /** Upper bound, in the same ISO shape as `value`. Forwarded to native `max`. */
25
- max?: string;
26
- /**
27
- * Native step granularity. Useful for `type="time"`:
28
- * `step="60"` (default) gives minute precision; `step="1"`
29
- * exposes seconds in the picker.
30
- */
31
- step?: number | string;
32
- required?: boolean;
33
- disabled?: boolean;
34
- hint?: string;
35
- errors?: string[];
36
- status?: 'error' | 'success';
37
- }
38
-
39
- const {
40
- id,
41
- label,
42
- name,
43
- type = 'date',
44
- value = '',
45
- min,
46
- max,
47
- step,
48
- required = false,
49
- disabled = false,
50
- hint,
51
- errors = [],
52
- status,
53
- ...rest
54
- } = Astro.props;
55
-
56
- const inputId = id ?? generateId('input');
57
- const hintId = `${inputId}-hint`;
58
- const errorsId = `${inputId}-errors`;
59
-
60
- const hasErrors = errors.length > 0;
61
- const computedStatus = status ?? (hasErrors ? 'error' : undefined);
62
-
63
- const describedByIds: string[] = [];
64
- if (hint) describedByIds.push(hintId);
65
- if (hasErrors) describedByIds.push(errorsId);
66
- const describedBy =
67
- describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
68
- ---
69
-
70
- <div class="pui-field" data-status={computedStatus}>
71
- <label class="pui-field__label" for={inputId}>
72
- {label}
73
- </label>
74
- <input
75
- class="pui-input"
76
- id={inputId}
77
- type={type}
78
- name={name}
79
- value={value}
80
- min={min}
81
- max={max}
82
- step={step}
83
- required={required}
84
- disabled={disabled}
85
- aria-invalid={hasErrors ? 'true' : undefined}
86
- aria-describedby={describedBy}
87
- {...rest}
88
- />
89
- {
90
- hint && (
91
- <p id={hintId} class="pui-field__hint">
92
- {hint}
93
- </p>
94
- )
95
- }
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
- </div>
@@ -1,106 +0,0 @@
1
- ---
2
- import './form.css';
3
- import { generateId } from '../../../utils/a11y/id.js';
4
-
5
- interface Props {
6
- /** Stable form ID — used by the consumer's `mountForm` script. */
7
- id?: string;
8
- submitLabel?: string;
9
- busy?: boolean;
10
- /** Top-level errors rendered in the error summary. */
11
- errors?: string[];
12
- /** Success/status message rendered in the polite live region. */
13
- statusMessage?: string;
14
- }
15
-
16
- const {
17
- id,
18
- submitLabel = 'Submit',
19
- busy = false,
20
- errors = [],
21
- statusMessage,
22
- } = Astro.props;
23
-
24
- const formId = id ?? generateId('form');
25
- ---
26
-
27
- <form
28
- class="pui-form"
29
- id={formId}
30
- data-pui-form
31
- novalidate
32
- aria-busy={busy ? 'true' : undefined}
33
- >
34
- {
35
- errors.length > 0 && (
36
- <div class="pui-form__error-summary" role="alert" aria-live="assertive">
37
- <p class="pui-form__error-summary-title">There was a problem</p>
38
- <ul class="pui-form__error-summary-list">
39
- {errors.map((error) => (
40
- <li>{error}</li>
41
- ))}
42
- </ul>
43
- </div>
44
- )
45
- }
46
-
47
- <slot />
48
-
49
- {
50
- statusMessage && (
51
- <div class="pui-form__status" aria-live="polite">
52
- {statusMessage}
53
- </div>
54
- )
55
- }
56
-
57
- <div class="pui-form__actions">
58
- <slot name="actions">
59
- <button
60
- class="pui-btn"
61
- data-variant="primary"
62
- type="submit"
63
- disabled={busy}
64
- >
65
- {submitLabel}
66
- </button>
67
- </slot>
68
- </div>
69
- </form>
70
-
71
- {
72
- /*
73
- Form.astro renders chrome only — it deliberately does NOT auto-mount
74
- `mountForm`. The controller takes `validators` and an async `onSubmit`
75
- handler, both of which are functions that can't be passed as Astro
76
- props. Consumers wire the controller themselves from a `<script>`
77
- block:
78
-
79
- ---
80
- import Form from '@pienter/ui/components/Form.astro';
81
- import TextInput from '@pienter/ui/components/TextInput.astro';
82
- ---
83
- <Form id="signup" submitLabel="Create account">
84
- <TextInput label="Email" name="email" type="email" />
85
- </Form>
86
- <script>
87
- import { mountForm } from '@pienter/ui/utils/controllers/form.js';
88
- import { isRequired, isEmail } from '@pienter/ui/utils/validation/rules.js';
89
- const el = document.getElementById('signup');
90
- if (el instanceof HTMLFormElement) {
91
- mountForm(el, {
92
- validators: { email: [isRequired(), isEmail()] },
93
- onSubmit: async (data) => {
94
- const res = await fetch('/api/signup', { method: 'POST', body: data });
95
- if (res.ok) return { ok: true, statusMessage: 'Account created' };
96
- const body = await res.json();
97
- return { ok: false, fieldErrors: body.fieldErrors, errors: body.errors };
98
- },
99
- });
100
- }
101
- </script>
102
-
103
- This explicit mount pattern is the locked Astro Form contract per the
104
- Form audit entry in CONVENTIONS.md.
105
- */
106
- }
@@ -1,147 +0,0 @@
1
- ---
2
- import './input-otp.css';
3
- import { generateId } from '../../../utils/a11y/id.js';
4
-
5
- interface Props {
6
- id?: string;
7
- label: string;
8
- name?: string;
9
- /**
10
- * Initial OTP string. Length matches `length` (Astro pads with empty
11
- * characters or trims as needed before rendering slots).
12
- */
13
- value?: string;
14
- /** Number of slots (default 6). */
15
- length?: number;
16
- /**
17
- * Per-character validation pattern as a string (regex source). Default
18
- * is digits-only `^[0-9]$`. Pass an alphanumeric pattern as a regex
19
- * source string when the OTP code includes letters. The string is
20
- * stored on a `data-pattern` attribute so the controller can rehydrate
21
- * the regex post-mount.
22
- */
23
- pattern?: string;
24
- disabled?: boolean;
25
- hint?: string;
26
- errors?: string[];
27
- status?: 'error' | 'success';
28
- }
29
-
30
- const {
31
- id,
32
- label,
33
- name,
34
- value = '',
35
- length = 6,
36
- pattern,
37
- disabled = false,
38
- hint,
39
- errors = [],
40
- status,
41
- ...rest
42
- } = Astro.props;
43
-
44
- const firstSlotId = id ?? generateId('input');
45
- const hintId = `${firstSlotId}-hint`;
46
- const errorsId = `${firstSlotId}-errors`;
47
-
48
- const hasErrors = errors.length > 0;
49
- const computedStatus = status ?? (hasErrors ? 'error' : undefined);
50
-
51
- const describedByIds: string[] = [];
52
- if (hint) describedByIds.push(hintId);
53
- if (hasErrors) describedByIds.push(errorsId);
54
- const describedBy =
55
- describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
56
-
57
- // Pad / trim seed value to the slot count.
58
- const slots = Array.from({ length }, (_, i) => value[i] ?? '');
59
- ---
60
-
61
- <div class="pui-field" data-status={computedStatus}>
62
- <label class="pui-field__label" for={firstSlotId}>
63
- {label}
64
- </label>
65
- <div
66
- class="pui-input-otp"
67
- data-pui-input-otp
68
- data-disabled={disabled ? 'true' : undefined}
69
- data-pattern={pattern}
70
- role="group"
71
- >
72
- {
73
- slots.map((char, i) => (
74
- <input
75
- class="pui-input-otp__slot pui-input"
76
- type="text"
77
- inputmode="numeric"
78
- autocomplete="one-time-code"
79
- maxlength="1"
80
- id={i === 0 ? firstSlotId : `${firstSlotId}-slot-${i + 1}`}
81
- aria-label={`Digit ${i + 1} of ${length}`}
82
- value={char}
83
- disabled={disabled}
84
- aria-invalid={i === 0 && hasErrors ? 'true' : undefined}
85
- aria-describedby={i === 0 ? describedBy : undefined}
86
- {...(i === 0 ? rest : {})}
87
- />
88
- ))
89
- }
90
- {
91
- name && (
92
- <input
93
- class="pui-input-otp__hidden"
94
- type="hidden"
95
- name={name}
96
- value={value}
97
- />
98
- )
99
- }
100
- </div>
101
- {
102
- hint && (
103
- <p id={hintId} class="pui-field__hint">
104
- {hint}
105
- </p>
106
- )
107
- }
108
- {
109
- hasErrors && (
110
- <ul id={errorsId} class="pui-field__hint" role="alert">
111
- {errors.map((error) => (
112
- <li>{error}</li>
113
- ))}
114
- </ul>
115
- )
116
- }
117
- </div>
118
-
119
- <script>
120
- import { mountInputOTP } from '@pienter/ui/utils/controllers/otp.js';
121
-
122
- // Hoisted module script — Astro de-dupes across instances.
123
- function mountAll(): void {
124
- const wrappers = document.querySelectorAll<HTMLElement>(
125
- '[data-pui-input-otp]',
126
- );
127
- wrappers.forEach((wrapper) => {
128
- if (wrapper.dataset.vInputOtpMounted === 'true') return;
129
- let pattern: RegExp | undefined;
130
- if (wrapper.dataset.pattern) {
131
- try {
132
- pattern = new RegExp(wrapper.dataset.pattern);
133
- } catch {
134
- pattern = undefined;
135
- }
136
- }
137
- mountInputOTP(wrapper, { pattern });
138
- wrapper.dataset.vInputOtpMounted = 'true';
139
- });
140
- }
141
-
142
- if (document.readyState === 'loading') {
143
- document.addEventListener('DOMContentLoaded', mountAll, { once: true });
144
- } else {
145
- mountAll();
146
- }
147
- </script>
@@ -1,13 +0,0 @@
1
- ---
2
- import './label.css';
3
-
4
- interface Props {
5
- for?: string;
6
- }
7
-
8
- const { for: htmlFor, ...rest } = Astro.props;
9
- ---
10
-
11
- <label class="pui-field__label" for={htmlFor} {...rest}>
12
- <slot />
13
- </label>
@@ -1,142 +0,0 @@
1
- ---
2
- import './number-field.css';
3
- import { generateId } from '../../../utils/a11y/id.js';
4
-
5
- interface Props {
6
- id?: string;
7
- label: string;
8
- name?: string;
9
- /**
10
- * Initial numeric value. Astro renders the value once at SSR time;
11
- * dynamic updates are handled by the consumer's own scripts (see
12
- * the consumer-wires-their-own-script pattern in CONVENTIONS) or
13
- * by the increment/decrement buttons via `mountNumberField`.
14
- */
15
- value?: number | string;
16
- min?: number;
17
- max?: number;
18
- step?: number | string;
19
- placeholder?: string;
20
- required?: boolean;
21
- disabled?: boolean;
22
- hint?: string;
23
- errors?: string[];
24
- status?: 'error' | 'success';
25
- }
26
-
27
- const {
28
- id,
29
- label,
30
- name,
31
- value,
32
- min,
33
- max,
34
- step,
35
- placeholder,
36
- required = false,
37
- disabled = false,
38
- hint,
39
- errors = [],
40
- status,
41
- ...rest
42
- } = Astro.props;
43
-
44
- const inputId = id ?? generateId('input');
45
- const hintId = `${inputId}-hint`;
46
- const errorsId = `${inputId}-errors`;
47
-
48
- const hasErrors = errors.length > 0;
49
- const computedStatus = status ?? (hasErrors ? 'error' : undefined);
50
-
51
- const describedByIds: string[] = [];
52
- if (hint) describedByIds.push(hintId);
53
- if (hasErrors) describedByIds.push(errorsId);
54
- const describedBy =
55
- describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
56
- ---
57
-
58
- <div class="pui-field" data-status={computedStatus}>
59
- <label class="pui-field__label" for={inputId}>
60
- {label}
61
- </label>
62
- <div class="pui-number-field" data-pui-number-field>
63
- <input
64
- class="pui-number-field__input pui-input"
65
- type="number"
66
- id={inputId}
67
- name={name}
68
- min={min}
69
- max={max}
70
- step={step}
71
- placeholder={placeholder}
72
- required={required}
73
- disabled={disabled}
74
- value={value}
75
- aria-invalid={hasErrors ? 'true' : undefined}
76
- aria-describedby={describedBy}
77
- {...rest}
78
- />
79
- <div class="pui-number-field__steppers">
80
- <button
81
- type="button"
82
- class="pui-number-field__step"
83
- data-direction="increment"
84
- aria-label="Increase"
85
- disabled={disabled}
86
- >
87
- <span aria-hidden="true">+</span>
88
- </button>
89
- <button
90
- type="button"
91
- class="pui-number-field__step"
92
- data-direction="decrement"
93
- aria-label="Decrease"
94
- disabled={disabled}
95
- >
96
- <span aria-hidden="true">&#x2212;</span>
97
- </button>
98
- </div>
99
- </div>
100
- {
101
- hint && (
102
- <p id={hintId} class="pui-field__hint">
103
- {hint}
104
- </p>
105
- )
106
- }
107
- {
108
- hasErrors && (
109
- <ul id={errorsId} class="pui-field__hint" role="alert">
110
- {errors.map((error) => (
111
- <li>{error}</li>
112
- ))}
113
- </ul>
114
- )
115
- }
116
- </div>
117
-
118
- <script>
119
- import { mountNumberField } from '@pienter/ui/utils/controllers/number-field.js';
120
-
121
- // Hoisted module script — Astro de-dupes this across every NumberField
122
- // instance on the page. Mount the controller on each [data-pui-number-field]
123
- // wrapper present at DOM-ready. See CONVENTIONS § hoisted-vs-inline rule.
124
- function mountAll(): void {
125
- const wrappers = document.querySelectorAll<HTMLElement>(
126
- '[data-pui-number-field]',
127
- );
128
- wrappers.forEach((wrapper) => {
129
- // Idempotency guard: skip wrappers that have already been mounted
130
- // (e.g. on Astro view transitions / partial re-renders).
131
- if (wrapper.dataset.vNumberFieldMounted === 'true') return;
132
- mountNumberField(wrapper);
133
- wrapper.dataset.vNumberFieldMounted = 'true';
134
- });
135
- }
136
-
137
- if (document.readyState === 'loading') {
138
- document.addEventListener('DOMContentLoaded', mountAll, { once: true });
139
- } else {
140
- mountAll();
141
- }
142
- </script>
@@ -1,105 +0,0 @@
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>