@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,201 @@
1
+ @layer components {
2
+ .pui-table-wrap {
3
+ overflow: auto;
4
+
5
+ /* the scroll port is the reference for the loading overlay's width, so its
6
+ spinner can stay centred in what you can actually see */
7
+ container-type: inline-size;
8
+ background: var(--bg-clr-surface);
9
+ border: var(--stroke-sm) solid var(--border-clr-base);
10
+ border-radius: var(--radius-sm); /* matches the button radius */
11
+ }
12
+
13
+ /* The table owns the column tracks once via --pui-table-cols; head, body and row
14
+ are subgrids spanning 1/-1, so one track value moves the header and every row. */
15
+ .pui-table {
16
+ display: grid;
17
+ grid-template-columns: var(--pui-table-cols, auto);
18
+ width: 100%;
19
+ min-width: 34rem; /* narrow-screen horizontal-scroll fallback */
20
+ font-size: var(--step--1);
21
+ }
22
+
23
+ .pui-table__head,
24
+ .pui-table__body,
25
+ .pui-table__row {
26
+ display: grid;
27
+ grid-template-columns: subgrid;
28
+ grid-column: 1 / -1;
29
+ }
30
+
31
+ /* Containing block for the loading overlay, which is a child of the body so it
32
+ covers the rows and only the rows. */
33
+ .pui-table__body {
34
+ position: relative;
35
+ }
36
+
37
+ /* ===== rows ===== */
38
+
39
+ .pui-table__row {
40
+ /* Top borders give the row separators; the header row zeroes its own, so the
41
+ first body row's top border is the header divider and the last row stays clean. */
42
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
43
+ transition: background-color var(--duration-base) var(--ease-base);
44
+
45
+ &:hover {
46
+ background: var(--bg-clr-surface-2);
47
+ }
48
+
49
+ &[data-head] {
50
+ border-block-start: 0;
51
+ }
52
+
53
+ &[data-selected='true'] {
54
+ background: var(--bg-clr-brand-soft);
55
+ }
56
+
57
+ &[data-clickable='true'] {
58
+ cursor: pointer;
59
+ }
60
+ }
61
+
62
+ /* ===== cells ===== */
63
+
64
+ .pui-table__cell {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: var(--space-2xs);
68
+ min-width: 0;
69
+ padding: var(--space-xs) var(--space-s);
70
+ text-align: left;
71
+
72
+ &[data-head] {
73
+ background: var(--bg-clr-surface-2);
74
+ color: var(--text-clr-base);
75
+ font-weight: var(--fw-control);
76
+ }
77
+
78
+ &[data-select] {
79
+ flex: 0 0 auto;
80
+ }
81
+
82
+ &[data-align='end'] {
83
+ justify-content: flex-end;
84
+ text-align: right;
85
+ font-variant-numeric: tabular-nums; /* numeric cells read cleaner */
86
+ }
87
+
88
+ &[data-align='center'] {
89
+ justify-content: center;
90
+ text-align: center;
91
+ }
92
+ }
93
+
94
+ /* ===== sort affordance ===== */
95
+
96
+ /* One rule for both tiers — a <button> in Vue, an <a> in Astro — so the resets below
97
+ deliberately cover both elements. */
98
+ .pui-table__sort {
99
+ display: inline-flex;
100
+ align-items: center;
101
+ gap: var(--space-3xs);
102
+ padding: 0;
103
+ border: 0;
104
+ background: transparent;
105
+ color: inherit;
106
+ font: inherit;
107
+ font-weight: inherit;
108
+ text-decoration: none; /* the <a> tier */
109
+ cursor: pointer;
110
+
111
+ &:focus-visible {
112
+ outline: var(--outline-width) solid var(--border-clr-strong);
113
+ outline-offset: var(--outline-offset);
114
+ }
115
+ }
116
+
117
+ /* The glyph carries the direction (Table swaps chevron-up-down / -up / -down);
118
+ colour only separates the unsorted affordance from the sorted column. */
119
+ .pui-table__sort-arrow {
120
+ display: inline-flex;
121
+
122
+ /* the dim rung reads as an affordance; sorted inherits the header's own colour */
123
+ &[data-sort='none'] {
124
+ --pui-icon-clr: var(--icon-clr-dim);
125
+ }
126
+ }
127
+
128
+ /* ===== selection ===== */
129
+
130
+ /* bare select-row checkbox — intentionally NOT the field Checkbox, which
131
+ always renders a <label> and is too heavy for a selection gutter */
132
+ .pui-table__check {
133
+ width: var(--icon-size-md);
134
+ height: var(--icon-size-md);
135
+ accent-color: var(--bg-clr-brand);
136
+ cursor: pointer;
137
+
138
+ &:focus-visible {
139
+ outline: var(--outline-width) solid var(--outline-clr-base);
140
+ outline-offset: var(--outline-offset);
141
+ }
142
+ }
143
+
144
+ /* ===== loading ===== */
145
+
146
+ /* Restates background-color: this outranks .pui-table__row and transition is a
147
+ shorthand, so opacity alone would drop the hover fade. */
148
+ .pui-table__body > .pui-table__row {
149
+ transition:
150
+ background-color var(--duration-base) var(--ease-base),
151
+ opacity var(--duration-base) var(--ease-base);
152
+ }
153
+
154
+ /* Rows only — the overlay is a body child and must not dim with them. */
155
+ .pui-table[aria-busy='true'] .pui-table__body > .pui-table__row {
156
+ opacity: 0.65;
157
+ }
158
+
159
+ /* The overlay is absolute and adds no height, so a row-less body would collapse to
160
+ nothing; busy-and-childless only, so a populated table never shifts — see AUDIT.md. */
161
+ .pui-table[aria-busy='true'] .pui-table__body:not(:has(> .pui-table__row)) {
162
+ min-height: var(--pui-table-loading-min-h, 9rem);
163
+ }
164
+
165
+ /* A <tr> covering the body, so the spinner centres on the rows and the header
166
+ is untouched — no measuring, and it stays correct if the header resizes. */
167
+ .pui-table__loading {
168
+ position: absolute;
169
+ inset: 0;
170
+ z-index: 1;
171
+
172
+ /* the tint gives the blur a surface to sit in; without it the frosting
173
+ reads as a smudge rather than a pane */
174
+ background: color-mix(in srgb, var(--bg-clr-surface) 45%, transparent);
175
+ backdrop-filter: blur(3px);
176
+ -webkit-backdrop-filter: blur(3px);
177
+
178
+ /* the text ramp, not the brand — over the blur a brand hue reads as decoration */
179
+ --pui-icon-clr: var(--text-clr-base);
180
+
181
+ /* Centring lives on the cell: the body spans the full scrollable width, so sticky
182
+ + 100cqi is what keeps the spinner inside the scroll port — see AUDIT.md. */
183
+ & > td {
184
+ position: sticky;
185
+ left: 0;
186
+ display: grid;
187
+ place-items: center;
188
+ width: 100cqi;
189
+ height: 100%;
190
+ }
191
+ }
192
+
193
+ /* ===== empty state ===== */
194
+
195
+ .pui-table__empty {
196
+ grid-column: 1 / -1;
197
+ padding: var(--space-l);
198
+ color: var(--text-clr-muted);
199
+ text-align: center;
200
+ }
201
+ }
@@ -0,0 +1,35 @@
1
+ import type { ComputedRef, InjectionKey } from 'vue';
2
+
3
+ /** Cell + header alignment. `'end'` is the numeric case. */
4
+ export type Align = 'start' | 'end' | 'center';
5
+
6
+ /** A table column. Layout only — content is authored in slots (ADR 0004). */
7
+ export interface Column {
8
+ /** Unique id. Also the slot name (`#<key>` on `TableRow`, `#cell:<key>` on `DataTable`) and the `DataTable` field accessor `row[key]`. */
9
+ key: string;
10
+ /** Header text. */
11
+ label: string;
12
+ /** A CSS grid track fed into the table's `grid-template-columns`: `'auto'`, `'1fr'`, `'minmax(180px, 1fr)'`. Defaults to `'auto'`. */
13
+ width?: string;
14
+ /** Cell + header alignment. */
15
+ align?: Align;
16
+ /** Renders a focusable sort affordance in the header; emits `sort` with this `key`. Never mutates data. */
17
+ sortable?: boolean;
18
+ }
19
+
20
+ /* Defined in `utils/sort` (sorting is a list concept), re-exported so a consumer
21
+ can take `Column` and `SortState` from one import. */
22
+ export type {
23
+ Sort,
24
+ SortDirection,
25
+ SortState,
26
+ } from '../../../utils/sort/index.js';
27
+
28
+ /** Provided by `Table` so `TableRow` reads column layout instead of taking `columns` again. */
29
+ export interface TableColumnsContext {
30
+ columns: ComputedRef<Column[]>;
31
+ selectable: ComputedRef<boolean>;
32
+ clickable: ComputedRef<boolean>;
33
+ }
34
+ export const TABLE_COLUMNS: InjectionKey<TableColumnsContext> =
35
+ Symbol('pui-table-columns');
@@ -0,0 +1,7 @@
1
+ import type { Column } from './types.js';
2
+
3
+ /** Build the `grid-template-columns` value from column widths, prepending a track for the selection gutter. */
4
+ export function gridColumns(columns: Column[], selectable: boolean): string {
5
+ const tracks = columns.map((c) => c.width ?? 'auto');
6
+ return (selectable ? 'auto ' : '') + tracks.join(' ');
7
+ }
@@ -0,0 +1,36 @@
1
+ ---
2
+ import './breadcrumb.css';
3
+
4
+ interface Props {
5
+ items: { label: string; href?: string }[];
6
+ }
7
+
8
+ const { items, ...rest } = Astro.props;
9
+ ---
10
+
11
+ <nav aria-label="Breadcrumb" {...rest}>
12
+ <ol class="pui-breadcrumb">
13
+ {
14
+ items.map((item, index) => (
15
+ <>
16
+ {index > 0 && (
17
+ <li class="pui-breadcrumb__sep" aria-hidden="true">
18
+ /
19
+ </li>
20
+ )}
21
+ {index < items.length - 1 ? (
22
+ <li class="pui-breadcrumb__item">
23
+ <a href={item.href} class="pui-breadcrumb__link">
24
+ {item.label}
25
+ </a>
26
+ </li>
27
+ ) : (
28
+ <li class="pui-breadcrumb__item" aria-current="page">
29
+ {item.label}
30
+ </li>
31
+ )}
32
+ </>
33
+ ))
34
+ }
35
+ </ol>
36
+ </nav>
@@ -0,0 +1,36 @@
1
+ <template>
2
+ <nav aria-label="Breadcrumb">
3
+ <ol class="pui-breadcrumb">
4
+ <template v-for="(item, index) in items" :key="index">
5
+ <li
6
+ v-if="index > 0"
7
+ class="pui-breadcrumb__sep"
8
+ aria-hidden="true"
9
+ >
10
+ /
11
+ </li>
12
+ <li
13
+ v-if="index < items.length - 1"
14
+ class="pui-breadcrumb__item"
15
+ >
16
+ <a :href="item.href" class="pui-breadcrumb__link">{{
17
+ item.label
18
+ }}</a>
19
+ </li>
20
+ <li v-else class="pui-breadcrumb__item" aria-current="page">
21
+ {{ item.label }}
22
+ </li>
23
+ </template>
24
+ </ol>
25
+ </nav>
26
+ </template>
27
+
28
+ <script setup lang="ts">
29
+ defineProps<{
30
+ items: { label: string; href?: string }[];
31
+ }>();
32
+ </script>
33
+
34
+ <style>
35
+ @import './breadcrumb.css';
36
+ </style>
@@ -0,0 +1,37 @@
1
+ @layer components {
2
+ .pui-breadcrumb {
3
+ display: flex;
4
+ align-items: center;
5
+ gap: var(--space-2xs);
6
+ font-size: var(--step--1);
7
+ color: var(--text-clr-muted);
8
+ list-style: none;
9
+ padding: 0;
10
+ margin: 0;
11
+ flex-wrap: wrap;
12
+ }
13
+
14
+ .pui-breadcrumb__item {
15
+ display: inline-flex;
16
+ align-items: center;
17
+ gap: var(--space-2xs);
18
+ }
19
+
20
+ .pui-breadcrumb__link {
21
+ color: var(--text-clr-muted);
22
+ text-decoration: none;
23
+ }
24
+
25
+ .pui-breadcrumb__link:hover {
26
+ color: var(--text-clr-base);
27
+ }
28
+
29
+ .pui-breadcrumb__sep {
30
+ color: var(--border-clr-base);
31
+ }
32
+
33
+ .pui-breadcrumb__item[aria-current='page'] {
34
+ color: var(--text-clr-base);
35
+ font-weight: var(--fw-semibold);
36
+ }
37
+ }
@@ -0,0 +1,62 @@
1
+ ---
2
+ import './navbar.css';
3
+
4
+ interface Props {
5
+ brandMark?: string;
6
+ brandName?: string;
7
+ brandHref?: string;
8
+ links?: { label: string; href: string; current?: boolean }[];
9
+ ariaLabel?: string;
10
+ }
11
+
12
+ const {
13
+ brandMark,
14
+ brandName,
15
+ brandHref = '/',
16
+ links,
17
+ ariaLabel = 'Main navigation',
18
+ ...rest
19
+ } = Astro.props;
20
+
21
+ const hasBrandSlot = Astro.slots.has('brand');
22
+ ---
23
+
24
+ <header
25
+ class="pui-navbar container"
26
+ data-container-size="xl"
27
+ aria-label={ariaLabel}
28
+ {...rest}
29
+ >
30
+ <nav class="pui-navbar__content container-content">
31
+ {
32
+ hasBrandSlot ? (
33
+ <slot name="brand" />
34
+ ) : (
35
+ <a href={brandHref} class="pui-navbar__brand">
36
+ {brandMark && <span class="pui-navbar__mark">{brandMark}</span>}
37
+ {brandName && <span class="pui-navbar__wordmark">{brandName}</span>}
38
+ </a>
39
+ )
40
+ }
41
+ {
42
+ links?.length && (
43
+ <ul class="pui-navbar__list">
44
+ {links.map((link) => (
45
+ <li>
46
+ <a
47
+ class="pui-navbar__link"
48
+ href={link.href}
49
+ aria-current={link.current ? 'page' : undefined}
50
+ >
51
+ {link.label}
52
+ </a>
53
+ </li>
54
+ ))}
55
+ </ul>
56
+ )
57
+ }
58
+ <div class="pui-navbar__actions">
59
+ <slot name="actions" />
60
+ </div>
61
+ </nav>
62
+ </header>
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <nav class="pui-navbar" :aria-label="ariaLabel">
3
+ <slot name="brand">
4
+ <a :href="brandHref" class="pui-navbar__brand">
5
+ <span v-if="brandMark" class="pui-navbar__mark">{{
6
+ brandMark
7
+ }}</span>
8
+ <span v-if="brandName" class="pui-navbar__wordmark">{{
9
+ brandName
10
+ }}</span>
11
+ </a>
12
+ </slot>
13
+ <ul v-if="links?.length" class="pui-navbar__list">
14
+ <li v-for="link in links" :key="link.href">
15
+ <a
16
+ class="pui-navbar__link"
17
+ :href="link.href"
18
+ :aria-current="link.current ? 'page' : undefined"
19
+ >{{ link.label }}</a
20
+ >
21
+ </li>
22
+ </ul>
23
+ <div class="pui-navbar__actions">
24
+ <slot name="actions" />
25
+ </div>
26
+ </nav>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ withDefaults(
31
+ defineProps<{
32
+ brandMark?: string;
33
+ brandName?: string;
34
+ brandHref?: string;
35
+ links?: { label: string; href: string; current?: boolean }[];
36
+ ariaLabel?: string;
37
+ }>(),
38
+ {
39
+ brandMark: undefined,
40
+ brandName: undefined,
41
+ brandHref: '/',
42
+ links: undefined,
43
+ ariaLabel: 'Main navigation',
44
+ },
45
+ );
46
+ </script>
47
+
48
+ <style>
49
+ @import './navbar.css';
50
+ </style>
@@ -0,0 +1,77 @@
1
+ @layer components {
2
+ .pui-navbar {
3
+ background: hsl(from var(--bg-clr-base) h s l / 0.92);
4
+ backdrop-filter: blur(8px);
5
+ border-block-end: var(--stroke-sm) solid var(--border-clr-base);
6
+ }
7
+
8
+ .pui-navbar__content {
9
+ display: flex;
10
+ }
11
+
12
+ .pui-navbar__brand {
13
+ display: inline-flex;
14
+ align-items: center;
15
+ gap: var(--space-2xs);
16
+ color: var(--text-clr-base);
17
+ text-decoration: none;
18
+ }
19
+
20
+ .pui-navbar__mark {
21
+ width: 1.75rem;
22
+ height: 1.75rem;
23
+ background: var(--bg-clr-brand);
24
+ color: var(--bg-clr-brand-ink);
25
+ border: var(--stroke-md) solid var(--border-clr-strong);
26
+ border-radius: var(--radius-xs);
27
+ font-family: var(--ff-display);
28
+ font-weight: var(--fw-display);
29
+ display: grid;
30
+ place-items: center;
31
+ font-size: var(--step--1);
32
+ line-height: 1;
33
+ }
34
+
35
+ .pui-navbar__wordmark {
36
+ font-family: var(--ff-display);
37
+ font-weight: var(--fw-display);
38
+ font-size: var(--step-1);
39
+ letter-spacing: var(--ls-display);
40
+ }
41
+
42
+ .pui-navbar__list {
43
+ display: flex;
44
+ gap: var(--space-s);
45
+ list-style: none;
46
+ margin: 0;
47
+ padding: 0;
48
+ margin-inline-start: var(--space-xs);
49
+ }
50
+
51
+ .pui-navbar__link {
52
+ font-size: var(--step--1);
53
+ font-weight: var(--fw-semibold);
54
+ color: var(--text-clr-base);
55
+ text-decoration: none;
56
+ padding-block: var(--space-3xs);
57
+ }
58
+
59
+ .pui-navbar__link[aria-current='page'] {
60
+ border-block-end: 2px solid var(--border-clr-brand);
61
+ }
62
+
63
+ .pui-navbar__link:hover {
64
+ color: var(--text-clr-brand);
65
+ }
66
+
67
+ .pui-navbar__link:focus-visible {
68
+ outline: 3px solid var(--outline-clr-base);
69
+ outline-offset: 2px;
70
+ }
71
+
72
+ .pui-navbar__actions {
73
+ margin-inline-start: auto;
74
+ display: flex;
75
+ gap: var(--space-2xs);
76
+ }
77
+ }
@@ -0,0 +1,107 @@
1
+ <template>
2
+ <nav aria-label="Pagination">
3
+ <ul class="pui-pagination">
4
+ <li>
5
+ <button
6
+ class="pui-pagination__item"
7
+ :disabled="currentPage <= 1"
8
+ aria-label="Previous page"
9
+ @click="emit('update:currentPage', currentPage - 1)"
10
+ >
11
+ <Icon name="chevron-left" size="sm" />
12
+ </button>
13
+ </li>
14
+
15
+ <template v-for="page in visiblePages" :key="page">
16
+ <li v-if="page === '...'">
17
+ <span class="pui-pagination__ellipsis">&hellip;</span>
18
+ </li>
19
+ <li v-else>
20
+ <button
21
+ class="pui-pagination__item"
22
+ :aria-current="
23
+ page === currentPage ? 'page' : undefined
24
+ "
25
+ @click="emit('update:currentPage', page as number)"
26
+ >
27
+ {{ page }}
28
+ </button>
29
+ </li>
30
+ </template>
31
+
32
+ <li>
33
+ <button
34
+ class="pui-pagination__item"
35
+ :disabled="currentPage >= totalPages"
36
+ aria-label="Next page"
37
+ @click="emit('update:currentPage', currentPage + 1)"
38
+ >
39
+ <Icon name="chevron-right" size="sm" />
40
+ </button>
41
+ </li>
42
+ </ul>
43
+ </nav>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ import { computed } from 'vue';
48
+ import Icon from '../../display/icon/Icon.vue';
49
+
50
+ const props = withDefaults(
51
+ defineProps<{
52
+ currentPage: number;
53
+ totalPages: number;
54
+ maxVisible?: number;
55
+ }>(),
56
+ {
57
+ maxVisible: 5,
58
+ },
59
+ );
60
+
61
+ const emit = defineEmits<{
62
+ 'update:currentPage': [page: number];
63
+ }>();
64
+
65
+ const visiblePages = computed(() => {
66
+ const total = props.totalPages;
67
+ const current = props.currentPage;
68
+ const max = props.maxVisible;
69
+
70
+ if (total <= max) {
71
+ return Array.from({ length: total }, (_, i) => i + 1);
72
+ }
73
+
74
+ const pages: (number | '...')[] = [];
75
+
76
+ // Always show first page
77
+ pages.push(1);
78
+
79
+ // Calculate the range around the current page
80
+ const rangeStart = Math.max(2, current - 1);
81
+ const rangeEnd = Math.min(total - 1, current + 1);
82
+
83
+ // Add leading ellipsis if there is a gap after page 1
84
+ if (rangeStart > 2) {
85
+ pages.push('...');
86
+ }
87
+
88
+ // Add pages in the range
89
+ for (let i = rangeStart; i <= rangeEnd; i++) {
90
+ pages.push(i);
91
+ }
92
+
93
+ // Add trailing ellipsis if there is a gap before last page
94
+ if (rangeEnd < total - 1) {
95
+ pages.push('...');
96
+ }
97
+
98
+ // Always show last page
99
+ pages.push(total);
100
+
101
+ return pages;
102
+ });
103
+ </script>
104
+
105
+ <style>
106
+ @import './pagination.css';
107
+ </style>