@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.
Files changed (227) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONVENTIONS.md +1499 -0
  3. package/LICENSE +21 -0
  4. package/README.md +71 -0
  5. package/components/action/button/Button.astro +95 -0
  6. package/components/action/button/Button.vue +92 -0
  7. package/components/action/button/IconButton.astro +86 -0
  8. package/components/action/button/IconButton.vue +85 -0
  9. package/components/action/button/button.css +194 -0
  10. package/components/action/toggle/Toggle.vue +38 -0
  11. package/components/action/toggle/toggle.css +77 -0
  12. package/components/action/toggle-group/ToggleGroup.vue +67 -0
  13. package/components/action/toggle-group/toggle-group.css +78 -0
  14. package/components/display/avatar/Avatar.astro +17 -0
  15. package/components/display/avatar/Avatar.vue +30 -0
  16. package/components/display/avatar/AvatarStack.astro +9 -0
  17. package/components/display/avatar/AvatarStack.vue +11 -0
  18. package/components/display/avatar/avatar.css +58 -0
  19. package/components/display/badge/Badge.astro +15 -0
  20. package/components/display/badge/Badge.vue +23 -0
  21. package/components/display/badge/badge.css +56 -0
  22. package/components/display/empty/Empty.astro +9 -0
  23. package/components/display/empty/Empty.vue +11 -0
  24. package/components/display/empty/empty.css +39 -0
  25. package/components/display/icon/Icon.astro +52 -0
  26. package/components/display/icon/Icon.vue +57 -0
  27. package/components/display/icon/icon.css +47 -0
  28. package/components/feedback/alert/Alert.astro +52 -0
  29. package/components/feedback/alert/Alert.vue +60 -0
  30. package/components/feedback/alert/alert.css +78 -0
  31. package/components/feedback/progress/Progress.astro +68 -0
  32. package/components/feedback/progress/Progress.vue +82 -0
  33. package/components/feedback/progress/progress.css +68 -0
  34. package/components/feedback/skeleton/Skeleton.astro +32 -0
  35. package/components/feedback/skeleton/Skeleton.vue +39 -0
  36. package/components/feedback/skeleton/skeleton.css +56 -0
  37. package/components/feedback/spinner/Spinner.astro +25 -0
  38. package/components/feedback/spinner/Spinner.vue +36 -0
  39. package/components/feedback/spinner/spinner.css +91 -0
  40. package/components/feedback/toast/Toast.astro +50 -0
  41. package/components/feedback/toast/Toast.vue +74 -0
  42. package/components/feedback/toast/toast.css +128 -0
  43. package/components/form/checkbox/Checkbox.astro +79 -0
  44. package/components/form/checkbox/Checkbox.vue +95 -0
  45. package/components/form/checkbox/checkbox.css +59 -0
  46. package/components/form/combobox/Combobox.vue +508 -0
  47. package/components/form/combobox/combobox.css +110 -0
  48. package/components/form/date-input/DateInput.astro +105 -0
  49. package/components/form/date-input/DateInput.vue +121 -0
  50. package/components/form/date-input/date-input.css +19 -0
  51. package/components/form/form/Form.astro +106 -0
  52. package/components/form/form/Form.vue +181 -0
  53. package/components/form/form/form.css +46 -0
  54. package/components/form/input-otp/InputOTP.astro +147 -0
  55. package/components/form/input-otp/InputOTP.vue +209 -0
  56. package/components/form/input-otp/input-otp.css +52 -0
  57. package/components/form/label/Label.astro +13 -0
  58. package/components/form/label/Label.vue +20 -0
  59. package/components/form/label/label.css +11 -0
  60. package/components/form/number-field/NumberField.astro +142 -0
  61. package/components/form/number-field/NumberField.vue +155 -0
  62. package/components/form/number-field/number-field.css +115 -0
  63. package/components/form/radio-group/RadioGroup.astro +105 -0
  64. package/components/form/radio-group/RadioGroup.vue +110 -0
  65. package/components/form/radio-group/radio-group.css +114 -0
  66. package/components/form/radio-group/types.ts +14 -0
  67. package/components/form/select/Segmented.vue +36 -0
  68. package/components/form/select/Select.astro +105 -0
  69. package/components/form/select/Select.vue +109 -0
  70. package/components/form/select/select.css +96 -0
  71. package/components/form/slider/Slider.astro +205 -0
  72. package/components/form/slider/Slider.vue +321 -0
  73. package/components/form/slider/slider.css +115 -0
  74. package/components/form/switch/Switch.astro +75 -0
  75. package/components/form/switch/Switch.vue +89 -0
  76. package/components/form/switch/switch.css +64 -0
  77. package/components/form/tags-input/TagsInput.astro +153 -0
  78. package/components/form/tags-input/TagsInput.vue +207 -0
  79. package/components/form/tags-input/tags-input.css +128 -0
  80. package/components/form/text-input/TextInput.astro +84 -0
  81. package/components/form/text-input/TextInput.vue +99 -0
  82. package/components/form/text-input/text-input.css +165 -0
  83. package/components/form/textarea/Textarea.astro +86 -0
  84. package/components/form/textarea/Textarea.vue +102 -0
  85. package/components/form/textarea/textarea.css +25 -0
  86. package/components/layout/accordion/Accordion.vue +59 -0
  87. package/components/layout/accordion/accordion.css +87 -0
  88. package/components/layout/card/Card.astro +13 -0
  89. package/components/layout/card/Card.vue +20 -0
  90. package/components/layout/card/card.css +55 -0
  91. package/components/layout/collapsible/Collapsible.vue +77 -0
  92. package/components/layout/collapsible/collapsible.css +76 -0
  93. package/components/layout/separator/Separator.astro +31 -0
  94. package/components/layout/separator/Separator.vue +33 -0
  95. package/components/layout/separator/separator.css +27 -0
  96. package/components/layout/table/DataTable.vue +127 -0
  97. package/components/layout/table/Table.astro +116 -0
  98. package/components/layout/table/Table.vue +146 -0
  99. package/components/layout/table/TableRow.vue +59 -0
  100. package/components/layout/table/table.css +201 -0
  101. package/components/layout/table/types.ts +35 -0
  102. package/components/layout/table/useTable.ts +7 -0
  103. package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
  104. package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
  105. package/components/navigation/breadcrumb/breadcrumb.css +37 -0
  106. package/components/navigation/navbar/Navbar.astro +62 -0
  107. package/components/navigation/navbar/Navbar.vue +50 -0
  108. package/components/navigation/navbar/navbar.css +77 -0
  109. package/components/navigation/pagination/Pagination.vue +107 -0
  110. package/components/navigation/pagination/pagination.css +53 -0
  111. package/components/navigation/sidebar/Sidebar.astro +132 -0
  112. package/components/navigation/sidebar/Sidebar.vue +174 -0
  113. package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
  114. package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
  115. package/components/navigation/sidebar/sidebar.css +303 -0
  116. package/components/navigation/sidebar/types.ts +72 -0
  117. package/components/navigation/tabs/Tabs.vue +84 -0
  118. package/components/navigation/tabs/tabs.css +39 -0
  119. package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
  120. package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
  121. package/components/overlay/alert-dialog/alert-dialog.css +57 -0
  122. package/components/overlay/command/Command.vue +356 -0
  123. package/components/overlay/command/command.css +179 -0
  124. package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
  125. package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
  126. package/components/overlay/modal/Modal.astro +66 -0
  127. package/components/overlay/modal/Modal.vue +85 -0
  128. package/components/overlay/modal/modal.css +60 -0
  129. package/components/overlay/popover/Popover.vue +113 -0
  130. package/components/overlay/popover/popover.css +53 -0
  131. package/components/overlay/sheet/Sheet.vue +88 -0
  132. package/components/overlay/sheet/sheet.css +108 -0
  133. package/components/overlay/tooltip/Tooltip.vue +210 -0
  134. package/components/overlay/tooltip/tooltip.css +50 -0
  135. package/composables/useUrlSort.ts +48 -0
  136. package/icons/alert-triangle.ts +1 -0
  137. package/icons/arrow-down.ts +1 -0
  138. package/icons/arrow-up-down.ts +5 -0
  139. package/icons/arrow-up.ts +1 -0
  140. package/icons/bell.ts +1 -0
  141. package/icons/check.ts +1 -0
  142. package/icons/chevron-down.ts +1 -0
  143. package/icons/chevron-left.ts +1 -0
  144. package/icons/chevron-right.ts +1 -0
  145. package/icons/chevron-up-down.ts +5 -0
  146. package/icons/chevron-up.ts +1 -0
  147. package/icons/circle-alert.ts +1 -0
  148. package/icons/circle-check.ts +1 -0
  149. package/icons/clipboard.ts +1 -0
  150. package/icons/download.ts +1 -0
  151. package/icons/edit.ts +1 -0
  152. package/icons/external-link.ts +1 -0
  153. package/icons/eye.ts +1 -0
  154. package/icons/file.ts +1 -0
  155. package/icons/filter.ts +1 -0
  156. package/icons/folder.ts +1 -0
  157. package/icons/image.ts +1 -0
  158. package/icons/inbox.ts +1 -0
  159. package/icons/index.ts +91 -0
  160. package/icons/info.ts +1 -0
  161. package/icons/layers.ts +1 -0
  162. package/icons/link-2.ts +1 -0
  163. package/icons/list.ts +1 -0
  164. package/icons/loader.ts +3 -0
  165. package/icons/menu.ts +1 -0
  166. package/icons/more-horizontal.ts +1 -0
  167. package/icons/more-vertical.ts +1 -0
  168. package/icons/plus-circle.ts +1 -0
  169. package/icons/plus.ts +1 -0
  170. package/icons/save.ts +1 -0
  171. package/icons/search.ts +1 -0
  172. package/icons/send.ts +1 -0
  173. package/icons/settings.ts +1 -0
  174. package/icons/tool.ts +1 -0
  175. package/icons/trash-2.ts +1 -0
  176. package/icons/trash.ts +1 -0
  177. package/icons/upload-cloud.ts +1 -0
  178. package/icons/upload.ts +1 -0
  179. package/icons/x.ts +1 -0
  180. package/package.json +150 -0
  181. package/styles/0-settings/colors.css +241 -0
  182. package/styles/0-settings/index.css +5 -0
  183. package/styles/0-settings/layout.css +52 -0
  184. package/styles/0-settings/motion.css +11 -0
  185. package/styles/0-settings/spacing.css +15 -0
  186. package/styles/0-settings/typography.css +37 -0
  187. package/styles/0-utils/index.css +1 -0
  188. package/styles/1-reset/index.css +1 -0
  189. package/styles/1-reset/reset.css +26 -0
  190. package/styles/2-base/base.css +42 -0
  191. package/styles/2-base/forms.css +23 -0
  192. package/styles/2-base/index.css +2 -0
  193. package/styles/3-layout/container.css +57 -0
  194. package/styles/3-layout/index.css +2 -0
  195. package/styles/3-layout/section.css +17 -0
  196. package/styles/5-utilities/accessibility.css +13 -0
  197. package/styles/5-utilities/index.css +2 -0
  198. package/styles/5-utilities/text.css +5 -0
  199. package/styles/main.css +8 -0
  200. package/styles/styles.d.ts +6 -0
  201. package/utils/a11y/focus.ts +68 -0
  202. package/utils/a11y/id.ts +10 -0
  203. package/utils/a11y/index.ts +9 -0
  204. package/utils/a11y/keyboard.ts +32 -0
  205. package/utils/a11y/live-region.ts +36 -0
  206. package/utils/controllers/dialog.ts +205 -0
  207. package/utils/controllers/disclosure.ts +117 -0
  208. package/utils/controllers/form.ts +524 -0
  209. package/utils/controllers/index.ts +39 -0
  210. package/utils/controllers/menu.ts +255 -0
  211. package/utils/controllers/number-field.ts +103 -0
  212. package/utils/controllers/otp.ts +252 -0
  213. package/utils/controllers/popover.ts +434 -0
  214. package/utils/controllers/sidebar.ts +610 -0
  215. package/utils/controllers/slider.ts +336 -0
  216. package/utils/controllers/tags-input.ts +255 -0
  217. package/utils/controllers/toast.ts +426 -0
  218. package/utils/dom/index.ts +1 -0
  219. package/utils/dom/scroll-lock.ts +48 -0
  220. package/utils/index.ts +3 -0
  221. package/utils/sort/index.ts +3 -0
  222. package/utils/sort/serialize.ts +19 -0
  223. package/utils/sort/state.ts +11 -0
  224. package/utils/sort/types.ts +15 -0
  225. package/utils/validation/form.ts +93 -0
  226. package/utils/validation/index.ts +13 -0
  227. package/utils/validation/rules.ts +31 -0
@@ -0,0 +1,165 @@
1
+ @layer components {
2
+ /* ===== pui-field (wrapper with label + hint) =====
3
+ *
4
+ * Default layout (`data-layout="stacked"` or unset): label above the
5
+ * control, hint and errors below — one column, vertical flow. Used by
6
+ * TextInput, Textarea, Select, NumberField, DateInput.
7
+ *
8
+ * Inline layout (`data-layout="inline"`): control left, label right
9
+ * (next to the control), hint and errors below the row spanning both
10
+ * columns. Used by compact form primitives (Switch, Checkbox,
11
+ * RadioGroup individual items) where the control is small enough to
12
+ * sit beside its label without the stacked label-above pattern. The
13
+ * control is the FIRST DOM child so a label-following-input source
14
+ * order can be styled via a grid template.
15
+ */
16
+
17
+ .pui-field {
18
+ display: grid;
19
+ gap: var(--space-2xs);
20
+ }
21
+
22
+ /* Zero <fieldset>'s user-agent defaults so it can host the pui-field grid
23
+ * scaffold cleanly. Without this, browsers paint a 2px groove border, add
24
+ * default block padding, and apply `min-inline-size: min-content` (which
25
+ * forces the fieldset to never shrink below its widest child). RadioGroup
26
+ * is the first form-primitive built on <fieldset>; future grouped-control
27
+ * primitives (checkbox groups, date-range pickers) reuse this carve-out. */
28
+ .pui-field:where(fieldset) {
29
+ border: 0;
30
+ padding: 0;
31
+ margin: 0;
32
+ min-inline-size: 0;
33
+ }
34
+
35
+ /* <legend> default rendering ("float" anchor, automatic padding-inline)
36
+ * misaligns inside a grid <fieldset>; zero those so the legend behaves
37
+ * like a normal block grid item, allowing `.pui-field`'s row gap to govern
38
+ * the label-to-control spacing. */
39
+ .pui-field__label:where(legend) {
40
+ padding: 0;
41
+ float: none;
42
+ }
43
+
44
+ .pui-field[data-layout='inline'] {
45
+ grid-template-columns: max-content 1fr;
46
+ column-gap: var(--space-xs);
47
+ align-items: center;
48
+ }
49
+
50
+ .pui-field[data-layout='inline'] .pui-field__label {
51
+ /* Pointer affordance for clickable label paired with a small control. */
52
+ cursor: pointer;
53
+ user-select: none;
54
+ }
55
+
56
+ .pui-field[data-layout='inline'] .pui-field__hint {
57
+ /* Hint and errors span both columns so they sit below the row. */
58
+ grid-column: 1 / -1;
59
+ }
60
+
61
+ .pui-field__label {
62
+ font-size: var(--step--1);
63
+ font-weight: var(--fw-bold);
64
+ }
65
+
66
+ .pui-field__hint {
67
+ font-size: var(--step--2);
68
+ color: var(--text-clr-muted);
69
+ margin: 0;
70
+ }
71
+
72
+ .pui-field[data-status='error'] .pui-field__hint {
73
+ color: var(--text-clr-danger);
74
+ }
75
+
76
+ .pui-field[data-status='success'] .pui-field__hint {
77
+ color: var(--text-clr-success);
78
+ }
79
+
80
+ /* ===== pui-input / pui-textarea (actual controls) ===== */
81
+
82
+ .pui-input,
83
+ .pui-textarea {
84
+ width: 100%;
85
+ padding: 0.65em 0.85em;
86
+ font: inherit;
87
+ background: var(--bg-clr-surface);
88
+ border: var(--stroke-sm) solid var(--border-clr-base);
89
+ border-radius: var(--radius-sm);
90
+ color: var(--text-clr-base);
91
+ transition:
92
+ border-color 0.12s ease,
93
+ box-shadow 0.12s ease;
94
+ }
95
+
96
+ .pui-textarea {
97
+ min-height: 6rem;
98
+ resize: vertical;
99
+ }
100
+
101
+ .pui-input:focus-visible,
102
+ .pui-textarea:focus-visible {
103
+ outline: none;
104
+ border-color: var(--border-clr-brand);
105
+ box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
106
+ }
107
+
108
+ .pui-input:disabled,
109
+ .pui-textarea:disabled {
110
+ background: var(--bg-clr-surface-2);
111
+ color: var(--text-clr-muted);
112
+ cursor: not-allowed;
113
+ }
114
+
115
+ /* Status states on parent .pui-field */
116
+
117
+ .pui-field[data-status='error'] .pui-input,
118
+ .pui-field[data-status='error'] .pui-textarea {
119
+ border-color: var(--border-clr-danger);
120
+ }
121
+
122
+ .pui-field[data-status='success'] .pui-input,
123
+ .pui-field[data-status='success'] .pui-textarea {
124
+ border-color: var(--border-clr-success);
125
+ }
126
+
127
+ /* ===== pui-inputgroup (prefix / suffix addons) ===== */
128
+
129
+ .pui-inputgroup {
130
+ display: flex;
131
+ border: var(--stroke-sm) solid var(--border-clr-base);
132
+ border-radius: var(--radius-sm);
133
+ overflow: hidden;
134
+ }
135
+
136
+ .pui-inputgroup:focus-within {
137
+ border-color: var(--border-clr-brand);
138
+ box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
139
+ }
140
+
141
+ .pui-inputgroup .pui-input {
142
+ border: 0;
143
+ border-radius: 0;
144
+ }
145
+
146
+ .pui-inputgroup .pui-input:focus-visible {
147
+ box-shadow: none;
148
+ }
149
+
150
+ .pui-inputgroup__addon {
151
+ display: grid;
152
+ place-items: center;
153
+ padding-inline: 0.85em;
154
+ background: var(--bg-clr-surface-2);
155
+ font-family: var(--ff-mono);
156
+ font-size: var(--step--1);
157
+ border-inline-start: var(--stroke-sm) solid var(--border-clr-base);
158
+ white-space: nowrap;
159
+ }
160
+
161
+ .pui-inputgroup__addon[data-position='prefix'] {
162
+ border-inline-start: 0;
163
+ border-inline-end: var(--stroke-sm) solid var(--border-clr-base);
164
+ }
165
+ }
@@ -0,0 +1,86 @@
1
+ ---
2
+ import './textarea.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ id?: string;
7
+ label: string;
8
+ name?: string;
9
+ value?: string;
10
+ rows?: number;
11
+ autocomplete?: string;
12
+ required?: boolean;
13
+ disabled?: boolean;
14
+ placeholder?: string;
15
+ hint?: string;
16
+ errors?: string[];
17
+ status?: 'error' | 'success';
18
+ autoResize?: boolean;
19
+ }
20
+
21
+ const {
22
+ id,
23
+ label,
24
+ name,
25
+ value = '',
26
+ rows,
27
+ autocomplete,
28
+ required = false,
29
+ disabled = false,
30
+ placeholder,
31
+ hint,
32
+ errors = [],
33
+ status,
34
+ autoResize = false,
35
+ ...rest
36
+ } = Astro.props;
37
+
38
+ const inputId = id ?? generateId('textarea');
39
+ const hintId = `${inputId}-hint`;
40
+ const errorsId = `${inputId}-errors`;
41
+
42
+ const hasErrors = errors.length > 0;
43
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
44
+
45
+ const describedByIds: string[] = [];
46
+ if (hint) describedByIds.push(hintId);
47
+ if (hasErrors) describedByIds.push(errorsId);
48
+ const describedBy =
49
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
50
+ ---
51
+
52
+ <div class="pui-field" data-status={computedStatus}>
53
+ <label class="pui-field__label" for={inputId}>
54
+ {label}
55
+ </label>
56
+ <textarea
57
+ class="pui-textarea"
58
+ id={inputId}
59
+ name={name}
60
+ rows={rows}
61
+ autocomplete={autocomplete}
62
+ required={required}
63
+ disabled={disabled}
64
+ placeholder={placeholder}
65
+ style={autoResize ? 'field-sizing: content' : undefined}
66
+ aria-invalid={hasErrors ? 'true' : undefined}
67
+ aria-describedby={describedBy}
68
+ {...rest}>{value}</textarea
69
+ >
70
+ {
71
+ hint && (
72
+ <p id={hintId} class="pui-field__hint">
73
+ {hint}
74
+ </p>
75
+ )
76
+ }
77
+ {
78
+ hasErrors && (
79
+ <ul id={errorsId} class="pui-field__hint" role="alert">
80
+ {errors.map((error) => (
81
+ <li>{error}</li>
82
+ ))}
83
+ </ul>
84
+ )
85
+ }
86
+ </div>
@@ -0,0 +1,102 @@
1
+ <template>
2
+ <div class="pui-field" :data-status="computedStatus">
3
+ <label class="pui-field__label" :for="inputId">{{ label }}</label>
4
+ <textarea
5
+ v-bind="$attrs"
6
+ :id="inputId"
7
+ class="pui-textarea"
8
+ :name="name"
9
+ :rows="rows"
10
+ :autocomplete="autocomplete"
11
+ :required="required"
12
+ :disabled="disabled"
13
+ :placeholder="placeholder"
14
+ :style="autoResize ? 'field-sizing: content' : undefined"
15
+ :aria-invalid="hasErrors ? 'true' : undefined"
16
+ :aria-describedby="describedBy"
17
+ :value="modelValue"
18
+ @input="
19
+ emit(
20
+ 'update:modelValue',
21
+ ($event.target as HTMLTextAreaElement).value,
22
+ )
23
+ "
24
+ @blur="emit('blur')"
25
+ />
26
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
27
+ <ul
28
+ v-if="errors.length"
29
+ :id="errorsId"
30
+ class="pui-field__hint"
31
+ role="alert"
32
+ >
33
+ <li v-for="error in errors" :key="error">{{ error }}</li>
34
+ </ul>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed } from 'vue';
40
+ import { generateId } from '../../../utils/a11y/id.js';
41
+
42
+ defineOptions({ inheritAttrs: false });
43
+
44
+ const props = withDefaults(
45
+ defineProps<{
46
+ id?: string;
47
+ label: string;
48
+ modelValue: string;
49
+ name?: string;
50
+ rows?: number;
51
+ autocomplete?: string;
52
+ required?: boolean;
53
+ disabled?: boolean;
54
+ placeholder?: string;
55
+ hint?: string;
56
+ errors?: string[];
57
+ status?: 'error' | 'success';
58
+ autoResize?: boolean;
59
+ }>(),
60
+ {
61
+ id: undefined,
62
+ name: undefined,
63
+ rows: undefined,
64
+ autocomplete: undefined,
65
+ required: false,
66
+ disabled: false,
67
+ placeholder: undefined,
68
+ hint: undefined,
69
+ errors: () => [],
70
+ status: undefined,
71
+ autoResize: false,
72
+ },
73
+ );
74
+
75
+ const emit = defineEmits<{
76
+ 'update:modelValue': [value: string];
77
+ blur: [];
78
+ }>();
79
+
80
+ const inputId = props.id ?? generateId('textarea');
81
+ const hintId = `${inputId}-hint`;
82
+ const errorsId = `${inputId}-errors`;
83
+
84
+ const hasErrors = computed(() => props.errors.length > 0);
85
+
86
+ const computedStatus = computed(() => {
87
+ if (props.status) return props.status;
88
+ if (hasErrors.value) return 'error';
89
+ return undefined;
90
+ });
91
+
92
+ const describedBy = computed(() => {
93
+ const ids: string[] = [];
94
+ if (props.hint) ids.push(hintId);
95
+ if (hasErrors.value) ids.push(errorsId);
96
+ return ids.length > 0 ? ids.join(' ') : undefined;
97
+ });
98
+ </script>
99
+
100
+ <style>
101
+ @import './textarea.css';
102
+ </style>
@@ -0,0 +1,25 @@
1
+ @layer components {
2
+ /* The shared `.pui-textarea` rules (sizing, padding, border, focus,
3
+ * disabled, status-driven recolour) live alongside `.pui-input` in
4
+ * `components/form/text-input/text-input.css` because both controls share
5
+ * the form-primitive scaffold (per CONVENTIONS § Component archetypes
6
+ * → Form-primitive wrapper). When a fifth+ primitive ships, those
7
+ * shared rules will move into a dedicated `form-field.css` per the
8
+ * deferred-extraction note in CONVENTIONS.
9
+ *
10
+ * This file only carries Textarea-specific rules that don't fit the
11
+ * shared scaffold — currently just the `:autoResize` opt-in styling.
12
+ */
13
+
14
+ /* Auto-resize variant — opt-in via the `autoResize` prop, which sets
15
+ * `style="field-sizing: content"` inline on the <textarea>. The
16
+ * inline style is the only documented Vue/Astro path, so the
17
+ * selector matches it directly. Browsers without `field-sizing`
18
+ * support fall back gracefully to the default fixed-rows/min-height
19
+ * box. */
20
+ .pui-textarea[style*='field-sizing'] {
21
+ /* Vertical resize handle is redundant when content sizing is on;
22
+ * the box grows with content on its own. */
23
+ resize: none;
24
+ }
25
+ }
@@ -0,0 +1,59 @@
1
+ <template>
2
+ <div ref="rootRef" class="pui-accordion">
3
+ <slot />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
9
+ import { mountDisclosure } from '../../../utils/controllers/disclosure.js';
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ /**
14
+ * Single-open mode: opening one item closes the others
15
+ * (mutually exclusive). When `false`, multiple items can be
16
+ * open at once. Default `true` matches the most common
17
+ * Accordion shape (FAQ, settings panels) and aligns with
18
+ * shadcn / Radix's `type="single"` default.
19
+ */
20
+ singleOpen?: boolean;
21
+ }>(),
22
+ {
23
+ singleOpen: true,
24
+ },
25
+ );
26
+
27
+ const rootRef = ref<HTMLDivElement | null>(null);
28
+ let teardown: (() => void) | null = null;
29
+
30
+ function mount(): void {
31
+ if (!rootRef.value) return;
32
+ teardown = mountDisclosure(rootRef.value, {
33
+ singleOpen: props.singleOpen,
34
+ });
35
+ }
36
+
37
+ onMounted(mount);
38
+
39
+ // `singleOpen` is read once at mount time. If a consumer toggles it
40
+ // at runtime we re-mount so the new policy takes effect immediately.
41
+ // This is rare (the prop is typically static per surface) but cheap.
42
+ watch(
43
+ () => props.singleOpen,
44
+ () => {
45
+ teardown?.();
46
+ teardown = null;
47
+ mount();
48
+ },
49
+ );
50
+
51
+ onBeforeUnmount(() => {
52
+ teardown?.();
53
+ teardown = null;
54
+ });
55
+ </script>
56
+
57
+ <style>
58
+ @import './accordion.css';
59
+ </style>
@@ -0,0 +1,87 @@
1
+ @layer components {
2
+ /* ============================================================
3
+ * Block — group wrapper. The slot-based API means consumers pass
4
+ * <details> children directly, so we style descendant <details>
5
+ * and <summary> to provide the cohesive Accordion look without
6
+ * requiring child-component classes.
7
+ * ============================================================ */
8
+ .pui-accordion {
9
+ display: flex;
10
+ flex-direction: column;
11
+ border: var(--stroke-sm) solid var(--border-clr-base);
12
+ border-radius: var(--radius-md);
13
+ background: var(--bg-clr-surface);
14
+ overflow: hidden; /* clips item border-radius at the corners */
15
+ }
16
+
17
+ /* ============================================================
18
+ * Item — each direct-child <details>. The Accordion provides the
19
+ * outer border + radius; items themselves only carry an inter-item
20
+ * separator.
21
+ * ============================================================ */
22
+ .pui-accordion > details {
23
+ border: 0;
24
+ border-radius: 0;
25
+ background: transparent;
26
+ }
27
+
28
+ .pui-accordion > details + details {
29
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
30
+ }
31
+
32
+ /* ============================================================
33
+ * Summary — the focusable / clickable trigger row. Consumers
34
+ * write a plain <summary> and inherit the styling.
35
+ * ============================================================ */
36
+ .pui-accordion > details > summary {
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: space-between;
40
+ gap: var(--space-s);
41
+ padding: var(--space-2xs) var(--space-s);
42
+ cursor: pointer;
43
+ font: inherit;
44
+ font-weight: var(--fw-semibold);
45
+ color: var(--text-clr-base);
46
+ list-style: none; /* hide default disclosure triangle (Firefox) */
47
+ }
48
+
49
+ .pui-accordion > details > summary::-webkit-details-marker {
50
+ display: none;
51
+ }
52
+
53
+ .pui-accordion > details > summary:hover {
54
+ background: var(--bg-clr-surface-2);
55
+ }
56
+
57
+ .pui-accordion > details > summary:focus-visible {
58
+ outline: 3px solid var(--outline-clr-base);
59
+ outline-offset: 2px;
60
+ }
61
+
62
+ /* ============================================================
63
+ * Chevron — rendered by the consumer inside <summary>; we provide
64
+ * the open-state rotation rule keyed off the parent <details>.
65
+ * Consumers who want a different affordance can override the
66
+ * descendant selector.
67
+ * ============================================================ */
68
+ .pui-accordion > details > summary .pui-icon {
69
+ flex: 0 0 auto;
70
+ color: var(--text-clr-muted);
71
+ transition: transform 180ms ease;
72
+ }
73
+
74
+ .pui-accordion > details[open] > summary .pui-icon {
75
+ transform: rotate(180deg);
76
+ }
77
+
78
+ /* ============================================================
79
+ * Panel content — every direct child of <details> that is NOT
80
+ * the <summary>. Adds padding and a top separator while open.
81
+ * ============================================================ */
82
+ .pui-accordion > details > :not(summary) {
83
+ padding: var(--space-s);
84
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
85
+ color: var(--text-clr-base);
86
+ }
87
+ }
@@ -0,0 +1,13 @@
1
+ ---
2
+ import './card.css';
3
+
4
+ interface Props {
5
+ variant?: 'bordered' | 'brand' | 'muted';
6
+ }
7
+
8
+ const { variant, ...rest } = Astro.props;
9
+ ---
10
+
11
+ <div class="pui-card" data-variant={variant} {...rest}>
12
+ <slot />
13
+ </div>
@@ -0,0 +1,20 @@
1
+ <template>
2
+ <div class="pui-card" :data-variant="variant">
3
+ <slot />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ withDefaults(
9
+ defineProps<{
10
+ variant?: 'bordered' | 'brand' | 'muted';
11
+ }>(),
12
+ {
13
+ variant: undefined,
14
+ },
15
+ );
16
+ </script>
17
+
18
+ <style>
19
+ @import './card.css';
20
+ </style>
@@ -0,0 +1,55 @@
1
+ @layer components {
2
+ .pui-card {
3
+ background: var(--bg-clr-surface);
4
+ border: var(--stroke-sm) solid var(--border-clr-base);
5
+ border-radius: var(--radius-md);
6
+ padding: var(--space-m);
7
+ display: grid;
8
+ gap: var(--space-xs);
9
+ }
10
+ .pui-card[data-variant='bordered'] {
11
+ border: var(--stroke-md) solid var(--border-clr-strong);
12
+ box-shadow: var(--shadow-spark);
13
+ border-radius: var(--radius-sm);
14
+ }
15
+ .pui-card[data-variant='brand'] {
16
+ background: var(--bg-clr-dark);
17
+ color: var(--text-clr-inverse);
18
+ }
19
+ .pui-card[data-variant='muted'] {
20
+ background: var(--bg-clr-surface-2);
21
+ border-color: transparent;
22
+ }
23
+ .pui-card__eyebrow {
24
+ font-family: var(--ff-mono);
25
+ font-size: var(--step--2);
26
+ text-transform: uppercase;
27
+ letter-spacing: var(--ls-eyebrow);
28
+ color: var(--text-clr-muted);
29
+ }
30
+ .pui-card[data-variant='brand'] .pui-card__eyebrow {
31
+ color: var(--text-clr-brand);
32
+ }
33
+ .pui-card__title {
34
+ font-family: var(--ff-display);
35
+ font-weight: var(--fw-display);
36
+ letter-spacing: var(--ls-display);
37
+ font-size: var(--step-1);
38
+ margin: 0;
39
+ }
40
+ .pui-card__body {
41
+ color: var(--text-clr-muted);
42
+ margin: 0;
43
+ }
44
+ .pui-card__meta {
45
+ font-size: var(--step--2);
46
+ color: var(--text-clr-muted);
47
+ display: flex;
48
+ gap: var(--space-xs);
49
+ }
50
+ .pui-card__footer {
51
+ display: flex;
52
+ gap: var(--space-xs);
53
+ margin-block-start: var(--space-2xs);
54
+ }
55
+ }
@@ -0,0 +1,77 @@
1
+ <template>
2
+ <details ref="rootRef" class="pui-collapsible" :open="open">
3
+ <summary class="pui-collapsible__trigger">
4
+ <span class="pui-collapsible__label">
5
+ <slot name="trigger">{{ label }}</slot>
6
+ </span>
7
+ <Icon
8
+ name="chevron-down"
9
+ size="sm"
10
+ class="pui-collapsible__chevron"
11
+ />
12
+ </summary>
13
+ <div class="pui-collapsible__content">
14
+ <slot />
15
+ </div>
16
+ </details>
17
+ </template>
18
+
19
+ <script setup lang="ts">
20
+ import { onBeforeUnmount, onMounted, ref } from 'vue';
21
+ import { mountDisclosure } from '../../../utils/controllers/disclosure.js';
22
+ import Icon from '../../display/icon/Icon.vue';
23
+
24
+ withDefaults(
25
+ defineProps<{
26
+ /**
27
+ * Controlled open state. When provided the parent owns the value
28
+ * via `v-model:open`; the component reflects it onto the native
29
+ * `<details>` `open` attribute. When omitted the native default
30
+ * (closed) applies until the user clicks the summary, at which
31
+ * point the browser flips the attribute and the controller's
32
+ * `onToggle` emits `update:open` so the consumer can pick up the
33
+ * value if they want to start tracking it.
34
+ */
35
+ open?: boolean;
36
+ /**
37
+ * Fallback label rendered inside the summary when the `#trigger`
38
+ * slot is empty. Plain string only — for icons-with-text or
39
+ * other rich content, supply a `#trigger` slot instead.
40
+ */
41
+ label?: string;
42
+ }>(),
43
+ {
44
+ open: undefined,
45
+ label: '',
46
+ },
47
+ );
48
+
49
+ const emit = defineEmits<{
50
+ 'update:open': [open: boolean];
51
+ }>();
52
+
53
+ const rootRef = ref<HTMLDetailsElement | null>(null);
54
+ let teardown: (() => void) | null = null;
55
+
56
+ onMounted(() => {
57
+ if (!rootRef.value) return;
58
+ // Collapsible is single-disclosure: no `singleOpen` coordination
59
+ // needed. The controller's only role here is to provide the
60
+ // standard mount/teardown lifecycle and a normalized callback —
61
+ // the native `<details>` element does the open/close work itself.
62
+ teardown = mountDisclosure(rootRef.value, {
63
+ onToggle: (_target, open) => {
64
+ emit('update:open', open);
65
+ },
66
+ });
67
+ });
68
+
69
+ onBeforeUnmount(() => {
70
+ teardown?.();
71
+ teardown = null;
72
+ });
73
+ </script>
74
+
75
+ <style>
76
+ @import './collapsible.css';
77
+ </style>