@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pienter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @pienter/ui
2
+
3
+ Shared Pienter UI package with framework-specific components, CSS, icons, and browser utilities.
4
+
5
+ This package publishes raw source files. Consuming projects compile the Vue, Astro, TypeScript, and CSS files with their own toolchain.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @pienter/ui
11
+ ```
12
+
13
+ Install the framework peer you use:
14
+
15
+ ```sh
16
+ npm install vue
17
+ npm install astro
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import Button from '@pienter/ui/components/Button.vue';
24
+ import '@pienter/ui/styles';
25
+ ```
26
+
27
+ Astro components are available beside Vue components:
28
+
29
+ ```astro
30
+ ---
31
+ import Button from '@pienter/ui/components/Button.astro';
32
+ ---
33
+ ```
34
+
35
+ Components use explicit flat public entrypoints. Intentional shared types are
36
+ available through module type entrypoints:
37
+
38
+ ```ts
39
+ import type { RadioOption } from '@pienter/ui/components/RadioGroup.types';
40
+ import type { SidebarItem } from '@pienter/ui/components/Sidebar.types';
41
+ ```
42
+
43
+ Utilities and icons are available through subpath imports:
44
+
45
+ ```ts
46
+ import { icons } from '@pienter/ui/icons';
47
+ import { generateId } from '@pienter/ui/utils/a11y/id.js';
48
+ ```
49
+
50
+ ### Themes
51
+
52
+ The default theme follows the operating-system color preference, using
53
+ Catppuccin Latte for light mode and Mocha for dark mode. Set `data-theme` on
54
+ the document element to override it:
55
+
56
+ ```html
57
+ <html data-theme="dark"></html>
58
+ ```
59
+
60
+ Supported explicit values are `light` and `dark`. Remove the attribute to
61
+ return to the system preference.
62
+
63
+ ## Development
64
+
65
+ ```sh
66
+ make verify
67
+ make smoke-install CONSUMER=/path/to/consumer-app
68
+ make publish
69
+ ```
70
+
71
+ See [CONVENTIONS.md](./CONVENTIONS.md) for component authoring rules.
@@ -0,0 +1,95 @@
1
+ ---
2
+ import './button.css';
3
+ import Icon from '../../display/icon/Icon.astro';
4
+ import Spinner from '../../feedback/spinner/Spinner.astro';
5
+ import type { IconName } from '../../../icons/index.js';
6
+
7
+ interface Props {
8
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'link';
9
+ size?: 'sm' | 'md';
10
+ disabled?: boolean;
11
+ loading?: boolean;
12
+ type?: 'button' | 'submit' | 'reset';
13
+ block?: boolean;
14
+ href?: string;
15
+ /** Leading icon from the shipped set. Use the `icon` slot for a custom <svg>. */
16
+ icon?: IconName;
17
+ /** Anchor target, only applied when `href` is set (renders an <a>). */
18
+ target?: string;
19
+ /** Anchor rel; defaults to a safe value for `target="_blank"`. */
20
+ rel?: string;
21
+ id?: string;
22
+ }
23
+
24
+ const {
25
+ variant = 'secondary',
26
+ size = 'md',
27
+ disabled = false,
28
+ loading = false,
29
+ type = 'button',
30
+ block = false,
31
+ href,
32
+ icon,
33
+ target,
34
+ rel,
35
+ id,
36
+ ...rest
37
+ } = Astro.props;
38
+
39
+ const Tag = href ? 'a' : 'button';
40
+ const isInert = disabled || loading;
41
+ // Button tier -> Icon size for the leading glyph (the Spinner is sized in button.css)
42
+ const iconSize = size === 'sm' ? 'sm' : 'md';
43
+ const hasIconSlot = Astro.slots.has('icon');
44
+ const hasIcon = !!icon || hasIconSlot;
45
+ const resolvedRel = href
46
+ ? (rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined))
47
+ : undefined;
48
+
49
+ // the icon slot wins over the `icon` name; flag the redundant combination in dev
50
+ if (import.meta.env.DEV && icon && hasIconSlot) {
51
+ console.warn(
52
+ '[pienter-ui] Button received both an `icon` name and an `icon` slot; the slot is used and `icon` is ignored.',
53
+ );
54
+ }
55
+ ---
56
+
57
+ <Tag
58
+ id={id}
59
+ class="pui-btn"
60
+ type={href ? undefined : type}
61
+ href={href}
62
+ target={href ? target : undefined}
63
+ rel={resolvedRel}
64
+ disabled={href ? undefined : isInert}
65
+ aria-disabled={href && isInert ? 'true' : undefined}
66
+ tabindex={href && isInert ? '-1' : undefined}
67
+ aria-busy={loading ? 'true' : undefined}
68
+ data-variant={variant}
69
+ data-size={size}
70
+ data-block={block ? 'true' : undefined}
71
+ data-state={loading ? 'loading' : undefined}
72
+ {...rest}
73
+ >
74
+ {
75
+ loading && (
76
+ <span class="pui-btn__spinner" aria-hidden="true">
77
+ <Spinner />
78
+ </span>
79
+ )
80
+ }
81
+ {
82
+ hasIcon && (
83
+ <span class="pui-btn__icon" aria-hidden="true">
84
+ {hasIconSlot ? (
85
+ <Icon size={iconSize}>
86
+ <slot name="icon" />
87
+ </Icon>
88
+ ) : (
89
+ <Icon name={icon} size={iconSize} />
90
+ )}
91
+ </span>
92
+ )
93
+ }
94
+ <span class="pui-btn__label"><slot /></span>
95
+ </Tag>
@@ -0,0 +1,92 @@
1
+ <template>
2
+ <component
3
+ :is="href ? 'a' : 'button'"
4
+ :id="id"
5
+ class="pui-btn"
6
+ :type="href ? undefined : type"
7
+ :href="href"
8
+ :target="href ? target : undefined"
9
+ :rel="resolvedRel"
10
+ :disabled="href ? undefined : disabled || loading"
11
+ :aria-disabled="href && (disabled || loading) ? 'true' : undefined"
12
+ :tabindex="href && (disabled || loading) ? '-1' : undefined"
13
+ :aria-busy="loading ? 'true' : undefined"
14
+ :data-variant="variant"
15
+ :data-size="size"
16
+ :data-block="block ? 'true' : undefined"
17
+ :data-state="loading ? 'loading' : undefined"
18
+ >
19
+ <span v-if="loading" class="pui-btn__spinner" aria-hidden="true">
20
+ <Spinner />
21
+ </span>
22
+ <span v-if="hasIcon" class="pui-btn__icon" aria-hidden="true">
23
+ <Icon v-if="$slots.icon" :size="iconSize"
24
+ ><slot name="icon"
25
+ /></Icon>
26
+ <Icon v-else :name="icon" :size="iconSize" />
27
+ </span>
28
+ <span class="pui-btn__label"><slot /></span>
29
+ </component>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ import { computed, useSlots } from 'vue';
34
+ import Icon from '../../display/icon/Icon.vue';
35
+ import Spinner from '../../feedback/spinner/Spinner.vue';
36
+ import type { IconName } from '../../../icons/index.js';
37
+
38
+ const props = withDefaults(
39
+ defineProps<{
40
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'link';
41
+ size?: 'sm' | 'md';
42
+ disabled?: boolean;
43
+ loading?: boolean;
44
+ type?: 'button' | 'submit' | 'reset';
45
+ block?: boolean;
46
+ href?: string;
47
+ /** Leading icon from the shipped set. Use the `icon` slot for a custom <svg>. */
48
+ icon?: IconName;
49
+ /** Anchor target, only applied when `href` is set (renders an <a>). */
50
+ target?: string;
51
+ /** Anchor rel; defaults to a safe value for `target="_blank"`. */
52
+ rel?: string;
53
+ id?: string;
54
+ }>(),
55
+ {
56
+ variant: 'secondary',
57
+ size: 'md',
58
+ disabled: false,
59
+ loading: false,
60
+ type: 'button',
61
+ block: false,
62
+ href: undefined,
63
+ icon: undefined,
64
+ target: undefined,
65
+ rel: undefined,
66
+ id: undefined,
67
+ },
68
+ );
69
+
70
+ const slots = useSlots();
71
+ const hasIcon = computed(() => !!props.icon || !!slots.icon);
72
+
73
+ // the icon slot wins over the `icon` name; flag the redundant combination in dev
74
+ if (props.icon && slots.icon) {
75
+ console.warn(
76
+ '[pienter-ui] Button received both an `icon` name and an `icon` slot; the slot is used and `icon` is ignored.',
77
+ );
78
+ }
79
+
80
+ // Button tier -> Icon size for the leading glyph (the Spinner is sized in button.css)
81
+ const iconSize = computed(() => (props.size === 'sm' ? 'sm' : 'md'));
82
+ const resolvedRel = computed(() =>
83
+ props.href
84
+ ? (props.rel ??
85
+ (props.target === '_blank' ? 'noopener noreferrer' : undefined))
86
+ : undefined,
87
+ );
88
+ </script>
89
+
90
+ <style>
91
+ @import './button.css';
92
+ </style>
@@ -0,0 +1,86 @@
1
+ ---
2
+ import './button.css';
3
+ import Icon from '../../display/icon/Icon.astro';
4
+ import Spinner from '../../feedback/spinner/Spinner.astro';
5
+ import type { IconName } from '../../../icons/index.js';
6
+
7
+ interface Props {
8
+ /** Accessible name for the icon-only control (required — there is no visible label). */
9
+ label: string;
10
+ /** Shipped icon to render. Omit and pass your own SVG via the default slot instead. */
11
+ name?: IconName;
12
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
13
+ size?: 'sm' | 'md';
14
+ disabled?: boolean;
15
+ loading?: boolean;
16
+ type?: 'button' | 'submit' | 'reset';
17
+ href?: string;
18
+ /** Anchor target, only applied when `href` is set (renders an <a>). */
19
+ target?: string;
20
+ /** Anchor rel; defaults to a safe value for `target="_blank"`. */
21
+ rel?: string;
22
+ id?: string;
23
+ }
24
+
25
+ const {
26
+ label,
27
+ name,
28
+ variant = 'secondary',
29
+ size = 'md',
30
+ disabled = false,
31
+ loading = false,
32
+ type = 'button',
33
+ href,
34
+ target,
35
+ rel,
36
+ id,
37
+ ...rest
38
+ } = Astro.props;
39
+
40
+ const Tag = href ? 'a' : 'button';
41
+ const isInert = disabled || loading;
42
+ const iconSize = size === 'sm' ? 'sm' : 'md';
43
+ const hasSlot = Astro.slots.has('default');
44
+ const resolvedRel = href
45
+ ? (rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined))
46
+ : undefined;
47
+
48
+ // the slot wins over the `name`; flag the redundant combination in dev
49
+ if (import.meta.env.DEV && name && hasSlot) {
50
+ console.warn(
51
+ '[pienter-ui] IconButton received both a `name` and slot content; the slot is used and `name` is ignored.',
52
+ );
53
+ }
54
+ ---
55
+
56
+ <Tag
57
+ id={id}
58
+ class="pui-icon-btn"
59
+ type={href ? undefined : type}
60
+ href={href}
61
+ target={href ? target : undefined}
62
+ rel={resolvedRel}
63
+ disabled={href ? undefined : isInert}
64
+ aria-disabled={href && isInert ? 'true' : undefined}
65
+ tabindex={href && isInert ? '-1' : undefined}
66
+ aria-busy={loading ? 'true' : undefined}
67
+ aria-label={label}
68
+ data-variant={variant}
69
+ data-size={size}
70
+ data-state={loading ? 'loading' : undefined}
71
+ {...rest}
72
+ >
73
+ {
74
+ loading ? (
75
+ <span class="pui-btn__spinner" aria-hidden="true">
76
+ <Spinner />
77
+ </span>
78
+ ) : hasSlot ? (
79
+ <Icon size={iconSize}>
80
+ <slot />
81
+ </Icon>
82
+ ) : (
83
+ <Icon name={name} size={iconSize} />
84
+ )
85
+ }
86
+ </Tag>
@@ -0,0 +1,85 @@
1
+ <template>
2
+ <component
3
+ :is="href ? 'a' : 'button'"
4
+ :id="id"
5
+ class="pui-icon-btn"
6
+ :type="href ? undefined : type"
7
+ :href="href"
8
+ :target="href ? target : undefined"
9
+ :rel="resolvedRel"
10
+ :disabled="href ? undefined : disabled || loading"
11
+ :aria-disabled="href && (disabled || loading) ? 'true' : undefined"
12
+ :tabindex="href && (disabled || loading) ? '-1' : undefined"
13
+ :aria-busy="loading ? 'true' : undefined"
14
+ :aria-label="label"
15
+ :data-variant="variant"
16
+ :data-size="size"
17
+ :data-state="loading ? 'loading' : undefined"
18
+ >
19
+ <span v-if="loading" class="pui-btn__spinner" aria-hidden="true">
20
+ <Spinner />
21
+ </span>
22
+ <Icon v-else-if="$slots.default" :size="iconSize"><slot /></Icon>
23
+ <Icon v-else :name="name" :size="iconSize" />
24
+ </component>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import { computed, useSlots } from 'vue';
29
+ import Icon from '../../display/icon/Icon.vue';
30
+ import Spinner from '../../feedback/spinner/Spinner.vue';
31
+ import type { IconName } from '../../../icons/index.js';
32
+
33
+ const props = withDefaults(
34
+ defineProps<{
35
+ /** Accessible name for the icon-only control (required — there is no visible label). */
36
+ label: string;
37
+ /** Shipped icon to render. Omit and pass your own SVG via the default slot instead. */
38
+ name?: IconName;
39
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
40
+ size?: 'sm' | 'md';
41
+ disabled?: boolean;
42
+ loading?: boolean;
43
+ type?: 'button' | 'submit' | 'reset';
44
+ href?: string;
45
+ /** Anchor target, only applied when `href` is set (renders an <a>). */
46
+ target?: string;
47
+ /** Anchor rel; defaults to a safe value for `target="_blank"`. */
48
+ rel?: string;
49
+ id?: string;
50
+ }>(),
51
+ {
52
+ name: undefined,
53
+ variant: 'secondary',
54
+ size: 'md',
55
+ disabled: false,
56
+ loading: false,
57
+ type: 'button',
58
+ href: undefined,
59
+ target: undefined,
60
+ rel: undefined,
61
+ id: undefined,
62
+ },
63
+ );
64
+
65
+ const slots = useSlots();
66
+
67
+ // the slot wins over the `name`; flag the redundant combination in dev
68
+ if (props.name && slots.default) {
69
+ console.warn(
70
+ '[pienter-ui] IconButton received both a `name` and slot content; the slot is used and `name` is ignored.',
71
+ );
72
+ }
73
+
74
+ const iconSize = computed(() => (props.size === 'sm' ? 'sm' : 'md'));
75
+ const resolvedRel = computed(() =>
76
+ props.href
77
+ ? (props.rel ??
78
+ (props.target === '_blank' ? 'noopener noreferrer' : undefined))
79
+ : undefined,
80
+ );
81
+ </script>
82
+
83
+ <style>
84
+ @import './button.css';
85
+ </style>
@@ -0,0 +1,194 @@
1
+ @layer components {
2
+ .pui-btn,
3
+ .pui-icon-btn {
4
+ position: relative;
5
+ display: inline-flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ gap: var(--control-gap);
9
+ font-weight: var(--fw-control);
10
+ border: var(--stroke-sm) solid var(--border-clr-input);
11
+ border-radius: var(--radius-sm);
12
+ background: var(--bg-clr-surface);
13
+ color: var(--text-clr-base);
14
+ cursor: pointer;
15
+ text-decoration: none;
16
+ line-height: var(--lh-control);
17
+ transition:
18
+ transform var(--duration-fast) var(--ease-base),
19
+ background-color var(--duration-base) var(--ease-base),
20
+ border-color var(--duration-base) var(--ease-base);
21
+
22
+ &:hover:not(:disabled) {
23
+ background: var(--bg-clr-surface-2);
24
+ }
25
+
26
+ &:active:not(:disabled) {
27
+ transform: translateY(1px);
28
+ }
29
+
30
+ &:focus-visible {
31
+ outline: var(--outline-width) solid
32
+ var(--btn-ring, var(--border-clr-strong));
33
+ outline-offset: var(--outline-offset);
34
+ }
35
+
36
+ &:disabled,
37
+ &[aria-disabled='true'] {
38
+ opacity: var(--opacity-disabled);
39
+ cursor: not-allowed;
40
+ transform: none;
41
+ }
42
+
43
+ /* Busy, not unavailable: full opacity, or --opacity-disabled compounds with the
44
+ spinner's own ramp. Must stay ordered after the disabled rule — specificity ties. */
45
+ &[data-state='loading'] {
46
+ opacity: 1;
47
+ }
48
+
49
+ /* aria-disabled isn't enforced natively (e.g. on <a>) */
50
+ &[aria-disabled='true'] {
51
+ pointer-events: none;
52
+ }
53
+
54
+ &[data-state='loading'] {
55
+ cursor: progress;
56
+ }
57
+
58
+ /* ===== variants ===== */
59
+
60
+ &[data-variant='primary'] {
61
+ background: var(--bg-clr-brand);
62
+ color: var(--bg-clr-brand-ink);
63
+ border-color: var(--bg-clr-brand);
64
+ box-shadow: var(--shadow-brand);
65
+ --btn-ring: var(--outline-clr-base);
66
+
67
+ &:hover:not(:disabled) {
68
+ background: var(--bg-clr-brand-hover);
69
+ border-color: var(--bg-clr-brand-hover);
70
+ }
71
+ }
72
+
73
+ &[data-variant='secondary'] {
74
+ background: var(--bg-clr-surface);
75
+ color: var(--text-clr-base);
76
+ }
77
+
78
+ &[data-variant='ghost'] {
79
+ background: transparent;
80
+ border-color: transparent;
81
+ color: var(--text-clr-base);
82
+
83
+ &:hover:not(:disabled) {
84
+ background: var(--bg-clr-surface-2);
85
+ }
86
+ }
87
+
88
+ &[data-variant='danger'] {
89
+ background: var(--bg-clr-danger);
90
+ color: var(--bg-clr-danger-ink);
91
+ border-color: var(--bg-clr-danger);
92
+ --btn-ring: var(--border-clr-danger);
93
+
94
+ &:hover:not(:disabled) {
95
+ background: var(--bg-clr-danger-hover);
96
+ border-color: var(--bg-clr-danger-hover);
97
+ }
98
+ }
99
+
100
+ /* ===== sizes ===== */
101
+
102
+ &[data-size='md'] {
103
+ min-height: var(--control-height-md);
104
+ padding-inline: var(--control-inline-pad-md);
105
+ font-size: var(--step--1);
106
+ }
107
+
108
+ &[data-size='sm'] {
109
+ min-height: var(--control-height-sm);
110
+ padding-inline: var(--control-inline-pad-sm);
111
+ font-size: var(--step--2);
112
+ }
113
+
114
+ /* ===== spinner ===== */
115
+
116
+ /* just centres the Spinner; the spin and the fade ramp live in spinner.css */
117
+ .pui-btn__spinner {
118
+ position: absolute;
119
+ inset: 0;
120
+ display: grid;
121
+ place-items: center;
122
+
123
+ /* Match the leading icon, not Spinner's standalone md; reaching in through the
124
+ block is what beats Spinner's own [data-size] rule. */
125
+ .pui-spinner {
126
+ --pui-spinner-size: 1.25rem;
127
+ }
128
+ }
129
+
130
+ &[data-size='sm'] .pui-btn__spinner .pui-spinner {
131
+ --pui-spinner-size: 1rem;
132
+ }
133
+ }
134
+
135
+ /* ===== button-only ===== */
136
+
137
+ .pui-btn {
138
+ &[data-variant='link'] {
139
+ background: transparent;
140
+ border: 0;
141
+ color: var(--text-clr-brand);
142
+ text-decoration: underline;
143
+ text-underline-offset: 3px; /* optical; no scale token fits */
144
+ --btn-ring: var(--outline-clr-base);
145
+
146
+ &:hover:not(:disabled) {
147
+ background: transparent;
148
+ color: var(--text-clr-brand-hover);
149
+ }
150
+ }
151
+
152
+ &[data-block='true'] {
153
+ width: 100%;
154
+ }
155
+
156
+ /* kept in flow while loading so the size holds; the spinner overlays */
157
+ &[data-state='loading'] {
158
+ .pui-btn__icon,
159
+ .pui-btn__label {
160
+ opacity: 0;
161
+ }
162
+ }
163
+
164
+ .pui-btn__icon {
165
+ display: inline-flex;
166
+ flex: none;
167
+ }
168
+
169
+ .pui-btn__label {
170
+ display: inline-flex;
171
+ align-items: center;
172
+ justify-content: center;
173
+ }
174
+ }
175
+
176
+ /* ===== icon-only (IconButton) ===== */
177
+
178
+ .pui-icon-btn {
179
+ gap: 0;
180
+
181
+ /* [data-size] specificity outweighs the shared size padding */
182
+ &[data-size] {
183
+ padding-inline: 0;
184
+ }
185
+
186
+ &[data-size='md'] {
187
+ width: var(--control-height-md);
188
+ }
189
+
190
+ &[data-size='sm'] {
191
+ width: var(--control-height-sm);
192
+ }
193
+ }
194
+ }
@@ -0,0 +1,38 @@
1
+ <template>
2
+ <button
3
+ class="pui-toggle"
4
+ type="button"
5
+ :data-tone="tone"
6
+ :data-size="size"
7
+ :disabled="disabled"
8
+ :aria-pressed="pressed ? 'true' : 'false'"
9
+ @click="emit('update:pressed', !pressed)"
10
+ >
11
+ <slot />
12
+ </button>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ withDefaults(
17
+ defineProps<{
18
+ pressed?: boolean;
19
+ disabled?: boolean;
20
+ size?: 'sm' | 'md' | 'lg';
21
+ tone?: 'default' | 'brand';
22
+ }>(),
23
+ {
24
+ pressed: false,
25
+ disabled: false,
26
+ size: 'md',
27
+ tone: 'default',
28
+ },
29
+ );
30
+
31
+ const emit = defineEmits<{
32
+ 'update:pressed': [value: boolean];
33
+ }>();
34
+ </script>
35
+
36
+ <style>
37
+ @import './toggle.css';
38
+ </style>