@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,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,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>
|