@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,143 @@
1
+ <template>
2
+ <div
3
+ :id="id"
4
+ ref="menuRef"
5
+ v-bind="$attrs"
6
+ class="pui-dropdown-menu"
7
+ role="menu"
8
+ data-state="closed"
9
+ :aria-labelledby="ariaLabelledBy"
10
+ >
11
+ <slot />
12
+ </div>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
17
+ import {
18
+ mountPopover,
19
+ type PopoverControl,
20
+ type Placement,
21
+ } from '../../../utils/controllers/popover.js';
22
+ import { mountMenu } from '../../../utils/controllers/menu.js';
23
+
24
+ defineOptions({ inheritAttrs: false });
25
+
26
+ const props = withDefaults(
27
+ defineProps<{
28
+ /**
29
+ * Required `id` for the dropdown menu element. The consumer
30
+ * wires their trigger button via the native HTML
31
+ * `popovertarget="<id>"` attribute, which `mountPopover`
32
+ * auto-detects on mount as the positioning anchor and as the
33
+ * focus-restore target on close.
34
+ */
35
+ id: string;
36
+ /**
37
+ * Optional controlled open state. When provided the parent
38
+ * owns the value via `v-model:open`; the component reflects
39
+ * it onto the native popover open state. When omitted the
40
+ * menu opens on declarative `popovertarget` clicks (the
41
+ * native pattern) and the `update:open` emit reports the
42
+ * change in either direction.
43
+ */
44
+ open?: boolean;
45
+ /**
46
+ * Initial placement; the controller flips automatically on
47
+ * viewport collision. Default: `'bottom-start'` — the
48
+ * convention for menus, which align under the trigger's left
49
+ * edge in LTR.
50
+ */
51
+ placement?: Placement;
52
+ /**
53
+ * Offset from the anchor edge in pixels. Default: `4` —
54
+ * tighter than Popover (8) because menus are extensions of
55
+ * their trigger button visually rather than separate
56
+ * surfaces.
57
+ */
58
+ offset?: number;
59
+ /**
60
+ * Optional id of a label element that names the menu (e.g. a
61
+ * visually-hidden heading describing the actions inside).
62
+ * Most dropdowns labeled by their trigger don't need this —
63
+ * the trigger already names the menu via the
64
+ * `popovertarget` association — so the prop is optional.
65
+ */
66
+ ariaLabelledBy?: string;
67
+ }>(),
68
+ {
69
+ open: undefined,
70
+ placement: 'bottom-start',
71
+ offset: 4,
72
+ ariaLabelledBy: undefined,
73
+ },
74
+ );
75
+
76
+ const emit = defineEmits<{
77
+ 'update:open': [value: boolean];
78
+ }>();
79
+
80
+ const menuRef = ref<HTMLElement | null>(null);
81
+ let popoverControl: PopoverControl | null = null;
82
+ let menuTeardown: (() => void) | null = null;
83
+
84
+ onMounted(() => {
85
+ if (!menuRef.value) return;
86
+ popoverControl = mountPopover(menuRef.value, {
87
+ placement: props.placement,
88
+ offset: props.offset,
89
+ // `modal: false` (default) → `popover="auto"` so the browser
90
+ // handles outside-click / focus-loss / Escape light-dismiss.
91
+ // The menu is non-modal — page content remains visible and
92
+ // interactive once the menu closes.
93
+ onOpen: () => {
94
+ emit('update:open', true);
95
+ // Move focus to the first menu item on open per the
96
+ // WAI-ARIA Menu pattern. The browser does NOT auto-focus
97
+ // popover content; the controller leaves focus management
98
+ // to consumers, so we explicitly walk into the menu here.
99
+ const firstItem = menuRef.value?.querySelector<HTMLElement>(
100
+ '[role="menuitem"]:not([disabled]):not([aria-disabled="true"]),' +
101
+ ' [role="menuitemcheckbox"]:not([disabled]):not([aria-disabled="true"]),' +
102
+ ' [role="menuitemradio"]:not([disabled]):not([aria-disabled="true"])',
103
+ );
104
+ firstItem?.focus();
105
+ },
106
+ onClose: () => emit('update:open', false),
107
+ });
108
+ menuTeardown = mountMenu({
109
+ menuEl: menuRef.value,
110
+ // Escape closes the menu and lets `popover="auto"`'s
111
+ // light-dismiss + the controller's `toggle` event handle
112
+ // focus restoration to the trigger.
113
+ onEscape: () => popoverControl?.hide(),
114
+ // Auto-close on item click is the standard menu UX. The
115
+ // consumer's own `@click` on each `<button role="menuitem">`
116
+ // runs first (DOM bubble); this callback fires from
117
+ // `mountMenu`'s container-level click listener and closes
118
+ // the menu after the action.
119
+ onItemActivate: () => popoverControl?.hide(),
120
+ });
121
+ if (props.open) popoverControl.show();
122
+ });
123
+
124
+ watch(
125
+ () => props.open,
126
+ (isOpen) => {
127
+ if (!popoverControl || isOpen === undefined) return;
128
+ if (isOpen) popoverControl.show();
129
+ else popoverControl.hide();
130
+ },
131
+ );
132
+
133
+ onBeforeUnmount(() => {
134
+ menuTeardown?.();
135
+ menuTeardown = null;
136
+ popoverControl?.teardown();
137
+ popoverControl = null;
138
+ });
139
+ </script>
140
+
141
+ <style>
142
+ @import './dropdown-menu.css';
143
+ </style>
@@ -0,0 +1,120 @@
1
+ @layer components {
2
+ .pui-dropdown-menu {
3
+ /* Reset native popover defaults — the user-agent centers
4
+ a popover at the viewport origin via inset/margin/padding
5
+ defaults that interfere with anchor positioning. */
6
+ margin: 0;
7
+ border: 0;
8
+ inset: auto;
9
+
10
+ /* Surface — lighter weight than Popover (uses --shadow-spark
11
+ instead of --shadow-spark-lg, --stroke-sm instead of
12
+ --stroke-md) because menus are extensions of their trigger
13
+ rather than standalone content surfaces. */
14
+ background: var(--bg-clr-surface);
15
+ border: var(--stroke-sm) solid var(--border-clr-base);
16
+ border-radius: var(--radius-sm);
17
+ box-shadow: var(--shadow-spark);
18
+ color: var(--text-clr-base);
19
+ min-width: 12rem;
20
+ padding: var(--space-3xs);
21
+
22
+ /* Position is set by the controller via inline `left`/`top`.
23
+ `position: fixed` is declared explicitly for self-doc — the
24
+ Popover API's top-layer rendering already produces the same
25
+ effect via the user-agent rule. */
26
+ position: fixed;
27
+
28
+ /* Stack items vertically with a hair-line gap so the
29
+ :focus-visible outline of one item never touches the
30
+ hover-bg of the neighbour. */
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: 1px;
34
+ }
35
+
36
+ /* Menu items — the consumer's `<button role="menuitem">`
37
+ elements (or the checkbox / radio variants). All three roles
38
+ get the same surface treatment so the menu reads visually
39
+ consistent regardless of which variant a row uses. */
40
+ .pui-dropdown-menu [role='menuitem'],
41
+ .pui-dropdown-menu [role='menuitemcheckbox'],
42
+ .pui-dropdown-menu [role='menuitemradio'] {
43
+ /* Reset button styles — menu items are intentionally borderless
44
+ full-width rows that look like list rows, not Buttons. */
45
+ appearance: none;
46
+ background: transparent;
47
+ border: 0;
48
+ text-align: start;
49
+ width: 100%;
50
+ padding: var(--space-2xs) var(--space-xs);
51
+ font: inherit;
52
+ font-size: var(--step--1);
53
+ color: var(--text-clr-base);
54
+ border-radius: var(--radius-xs);
55
+ cursor: pointer;
56
+
57
+ /* Multi-element row layout — icon + label + (optional) trailing
58
+ shortcut. Consumers compose freely inside the menuitem; the
59
+ flex container makes that composition look right by default. */
60
+ display: flex;
61
+ align-items: center;
62
+ gap: var(--space-2xs);
63
+ }
64
+
65
+ .pui-dropdown-menu
66
+ [role='menuitem']:hover:not(:disabled):not([aria-disabled='true']),
67
+ .pui-dropdown-menu
68
+ [role='menuitemcheckbox']:hover:not(:disabled):not(
69
+ [aria-disabled='true']
70
+ ),
71
+ .pui-dropdown-menu
72
+ [role='menuitemradio']:hover:not(:disabled):not(
73
+ [aria-disabled='true']
74
+ ) {
75
+ background: var(--bg-clr-surface-2);
76
+ }
77
+
78
+ .pui-dropdown-menu [role='menuitem']:focus-visible,
79
+ .pui-dropdown-menu [role='menuitemcheckbox']:focus-visible,
80
+ .pui-dropdown-menu [role='menuitemradio']:focus-visible {
81
+ /* Inset outline so the keyboard-focus ring stays inside the
82
+ menu surface rather than clipping against the menu's own
83
+ border. -2px offset matches the item's hover bg padding so
84
+ the outline traces the row precisely. */
85
+ outline: 3px solid var(--outline-clr-base);
86
+ outline-offset: -2px;
87
+ }
88
+
89
+ .pui-dropdown-menu [role='menuitem']:disabled,
90
+ .pui-dropdown-menu [role='menuitem'][aria-disabled='true'],
91
+ .pui-dropdown-menu [role='menuitemcheckbox']:disabled,
92
+ .pui-dropdown-menu [role='menuitemcheckbox'][aria-disabled='true'],
93
+ .pui-dropdown-menu [role='menuitemradio']:disabled,
94
+ .pui-dropdown-menu [role='menuitemradio'][aria-disabled='true'] {
95
+ color: var(--text-clr-muted);
96
+ cursor: not-allowed;
97
+ opacity: 0.6;
98
+ }
99
+
100
+ /* Separators between groups — `<hr role="separator">` is the
101
+ canonical pattern. Reset the user-agent's <hr> styling and
102
+ replace with a thin in-menu rule. */
103
+ .pui-dropdown-menu hr[role='separator'],
104
+ .pui-dropdown-menu [role='separator'] {
105
+ border: 0;
106
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
107
+ margin: var(--space-3xs) 0;
108
+ block-size: 0;
109
+ }
110
+
111
+ /* Group labels — non-interactive headings introducing a section
112
+ of menu items. Rendered as `<div role="presentation">` or a
113
+ `<span>` by the consumer. */
114
+ .pui-dropdown-menu [role='presentation']:not(hr) {
115
+ padding: var(--space-2xs) var(--space-xs);
116
+ font-size: var(--step--2);
117
+ color: var(--text-clr-muted);
118
+ font-weight: var(--fw-semibold);
119
+ }
120
+ }
@@ -0,0 +1,66 @@
1
+ ---
2
+ import './modal.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ id?: string;
7
+ open?: boolean;
8
+ persistent?: boolean;
9
+ title: string;
10
+ subtitle?: string;
11
+ }
12
+
13
+ const {
14
+ id,
15
+ open = false,
16
+ persistent = false,
17
+ title,
18
+ subtitle,
19
+ ...rest
20
+ } = Astro.props;
21
+
22
+ const modalId = id ?? generateId('modal');
23
+ const titleId = `${modalId}-title`;
24
+ ---
25
+
26
+ <dialog
27
+ id={modalId}
28
+ class="pui-modal"
29
+ data-state="closed"
30
+ data-pui-modal
31
+ data-persistent={persistent ? 'true' : undefined}
32
+ data-open-initially={open ? 'true' : undefined}
33
+ aria-labelledby={titleId}
34
+ {...rest}
35
+ >
36
+ <div class="pui-modal__head">
37
+ <h2 id={titleId} class="pui-modal__title">{title}</h2>
38
+ {subtitle && <p class="pui-modal__sub">{subtitle}</p>}
39
+ </div>
40
+ <div class="pui-modal__body"><slot /></div>
41
+ {
42
+ Astro.slots.has('footer') && (
43
+ <div class="pui-modal__foot">
44
+ <slot name="footer" />
45
+ </div>
46
+ )
47
+ }
48
+ </dialog>
49
+
50
+ <script>
51
+ import { mountDialog } from '@pienter/ui/utils/controllers/dialog.js';
52
+
53
+ document
54
+ .querySelectorAll<HTMLDialogElement>('dialog[data-pui-modal]')
55
+ .forEach((el) => {
56
+ const node = el as HTMLDialogElement & {
57
+ _dialog?: ReturnType<typeof mountDialog>;
58
+ };
59
+ if (node._dialog) return; // idempotent — guard against double-mounts (HMR, multiple <Modal> imports)
60
+ const persistent = el.dataset.persistent === 'true';
61
+ const openInitially = el.dataset.openInitially === 'true';
62
+ const control = mountDialog(el, { persistent });
63
+ node._dialog = control;
64
+ if (openInitially) control.open();
65
+ });
66
+ </script>
@@ -0,0 +1,85 @@
1
+ <template>
2
+ <dialog
3
+ ref="rootRef"
4
+ v-bind="$attrs"
5
+ class="pui-modal"
6
+ data-state="closed"
7
+ data-pui-modal
8
+ :aria-labelledby="titleId"
9
+ >
10
+ <div class="pui-modal__head">
11
+ <h2 :id="titleId" class="pui-modal__title">{{ title }}</h2>
12
+ <p v-if="subtitle" class="pui-modal__sub">{{ subtitle }}</p>
13
+ </div>
14
+ <div class="pui-modal__body"><slot /></div>
15
+ <div v-if="$slots.footer" class="pui-modal__foot">
16
+ <slot name="footer" />
17
+ </div>
18
+ </dialog>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
23
+ import { generateId } from '../../../utils/a11y/id.js';
24
+ import {
25
+ mountDialog,
26
+ type DialogControl,
27
+ } from '../../../utils/controllers/dialog.js';
28
+
29
+ defineOptions({ inheritAttrs: false });
30
+
31
+ const props = withDefaults(
32
+ defineProps<{
33
+ open: boolean;
34
+ title: string;
35
+ subtitle?: string;
36
+ persistent?: boolean;
37
+ }>(),
38
+ {
39
+ subtitle: undefined,
40
+ persistent: false,
41
+ },
42
+ );
43
+
44
+ const emit = defineEmits<{
45
+ 'update:open': [value: boolean];
46
+ close: [];
47
+ }>();
48
+
49
+ const rootRef = ref<HTMLDialogElement | null>(null);
50
+ const titleId = generateId('modal-title');
51
+ let control: DialogControl | null = null;
52
+
53
+ function requestClose() {
54
+ emit('update:open', false);
55
+ emit('close');
56
+ }
57
+
58
+ onMounted(() => {
59
+ if (!rootRef.value) return;
60
+ control = mountDialog(rootRef.value, {
61
+ role: 'dialog',
62
+ persistent: props.persistent,
63
+ onClose: requestClose,
64
+ });
65
+ if (props.open) control.open();
66
+ });
67
+
68
+ watch(
69
+ () => props.open,
70
+ (isOpen) => {
71
+ if (!control) return;
72
+ if (isOpen) control.open();
73
+ else control.close();
74
+ },
75
+ );
76
+
77
+ onBeforeUnmount(() => {
78
+ control?.teardown();
79
+ control = null;
80
+ });
81
+ </script>
82
+
83
+ <style>
84
+ @import './modal.css';
85
+ </style>
@@ -0,0 +1,60 @@
1
+ @layer components {
2
+ .pui-modal {
3
+ /* Reset browser default <dialog> styles when shown. */
4
+ margin: auto;
5
+ padding: 0;
6
+ border: var(--stroke-md) solid var(--border-clr-strong);
7
+ inset: 0;
8
+
9
+ /* Surface styling. */
10
+ background: var(--bg-clr-surface);
11
+ border-radius: var(--radius-md);
12
+ box-shadow: var(--shadow-spark-lg);
13
+
14
+ /* Layout (max-width / width — these don't affect display). */
15
+ max-width: 32rem;
16
+ width: calc(100% - var(--space-m) * 2);
17
+ }
18
+ /* Apply layout-driving display only when the dialog is actually open.
19
+ Browser default for <dialog>:not([open]) is display: none — preserve
20
+ that by NOT declaring display in the base rule. The data-state="closed"
21
+ attribute is preserved as a CSS hook for future transition work. */
22
+ .pui-modal[open] {
23
+ display: grid;
24
+ }
25
+ .pui-modal::backdrop {
26
+ background: hsl(from var(--border-clr-strong) h s l / 0.5);
27
+ }
28
+ .pui-modal:focus-visible {
29
+ outline: 3px solid var(--outline-clr-base);
30
+ outline-offset: 2px;
31
+ }
32
+ .pui-modal__head {
33
+ padding: var(--space-m) var(--space-m) 0;
34
+ display: grid;
35
+ gap: var(--space-2xs);
36
+ }
37
+ .pui-modal__title {
38
+ font-family: var(--ff-display);
39
+ font-weight: var(--fw-display);
40
+ letter-spacing: var(--ls-display);
41
+ font-size: var(--step-1);
42
+ margin: 0;
43
+ }
44
+ .pui-modal__sub {
45
+ color: var(--text-clr-muted);
46
+ margin: 0;
47
+ font-size: var(--step--1);
48
+ }
49
+ .pui-modal__body {
50
+ padding: var(--space-s) var(--space-m);
51
+ }
52
+ .pui-modal__foot {
53
+ padding: var(--space-xs) var(--space-m) var(--space-m);
54
+ display: flex;
55
+ gap: var(--space-2xs);
56
+ justify-content: flex-end;
57
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
58
+ margin-block-start: var(--space-2xs);
59
+ }
60
+ }
@@ -0,0 +1,113 @@
1
+ <template>
2
+ <div
3
+ :id="id"
4
+ ref="rootRef"
5
+ v-bind="$attrs"
6
+ class="pui-popover"
7
+ role="dialog"
8
+ data-state="closed"
9
+ data-pui-popover
10
+ :aria-labelledby="ariaLabelledBy"
11
+ >
12
+ <slot />
13
+ </div>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
18
+ import {
19
+ mountPopover,
20
+ type PopoverControl,
21
+ type Placement,
22
+ } from '../../../utils/controllers/popover.js';
23
+
24
+ defineOptions({ inheritAttrs: false });
25
+
26
+ const props = withDefaults(
27
+ defineProps<{
28
+ /**
29
+ * Required `id` for the popover element. Consumers wire their
30
+ * trigger button via the native HTML `popovertarget="<id>"`
31
+ * attribute, which the controller auto-detects on mount —
32
+ * this is the link between the trigger and the popover.
33
+ */
34
+ id: string;
35
+ /**
36
+ * Optional controlled open state. When provided, the parent
37
+ * owns the value via `v-model:open`; the component reflects
38
+ * it onto the native popover open state. When omitted the
39
+ * popover opens on declarative `popovertarget` clicks (the
40
+ * native pattern) and the `update:open` emit reports the
41
+ * change.
42
+ */
43
+ open?: boolean;
44
+ /**
45
+ * Initial placement; the controller flips automatically on
46
+ * viewport collision. Default: `'bottom-start'`.
47
+ */
48
+ placement?: Placement;
49
+ /**
50
+ * Offset from the anchor edge in pixels. Default: `8`.
51
+ */
52
+ offset?: number;
53
+ /**
54
+ * When `true`, uses `popover="manual"` — the popover stays
55
+ * open until programmatically dismissed (no light-dismiss).
56
+ * When `false` (default), uses `popover="auto"` for the
57
+ * standard click-outside / focus-out / Escape dismissal.
58
+ */
59
+ modal?: boolean;
60
+ /**
61
+ * Optional id of the heading element inside the popover, used
62
+ * for `aria-labelledby`. Consumers supply this when the
63
+ * popover has a visible title; tooltips skip it (the trigger
64
+ * already labels the surface).
65
+ */
66
+ ariaLabelledBy?: string;
67
+ }>(),
68
+ {
69
+ open: undefined,
70
+ placement: 'bottom-start',
71
+ offset: 8,
72
+ modal: false,
73
+ ariaLabelledBy: undefined,
74
+ },
75
+ );
76
+
77
+ const emit = defineEmits<{
78
+ 'update:open': [value: boolean];
79
+ }>();
80
+
81
+ const rootRef = ref<HTMLElement | null>(null);
82
+ let control: PopoverControl | null = null;
83
+
84
+ onMounted(() => {
85
+ if (!rootRef.value) return;
86
+ control = mountPopover(rootRef.value, {
87
+ placement: props.placement,
88
+ offset: props.offset,
89
+ modal: props.modal,
90
+ onOpen: () => emit('update:open', true),
91
+ onClose: () => emit('update:open', false),
92
+ });
93
+ if (props.open) control.show();
94
+ });
95
+
96
+ watch(
97
+ () => props.open,
98
+ (isOpen) => {
99
+ if (!control || isOpen === undefined) return;
100
+ if (isOpen) control.show();
101
+ else control.hide();
102
+ },
103
+ );
104
+
105
+ onBeforeUnmount(() => {
106
+ control?.teardown();
107
+ control = null;
108
+ });
109
+ </script>
110
+
111
+ <style>
112
+ @import './popover.css';
113
+ </style>
@@ -0,0 +1,53 @@
1
+ @layer components {
2
+ .pui-popover {
3
+ /* Reset native popover defaults — the browser centers a popover
4
+ at the viewport origin via inset/margin/padding/border defaults
5
+ that interfere with anchor positioning. */
6
+ margin: 0;
7
+ padding: 0;
8
+ border: 0;
9
+ inset: auto;
10
+
11
+ /* Surface — matches the dialog family for visual consistency
12
+ across overlays. */
13
+ background: var(--bg-clr-surface);
14
+ border: var(--stroke-md) solid var(--border-clr-strong);
15
+ border-radius: var(--radius-md);
16
+ box-shadow: var(--shadow-spark-lg);
17
+ color: var(--text-clr-base);
18
+
19
+ /* Default sizing — generous max-width prevents narrow popovers
20
+ on long content; consumers override via class for surfaces
21
+ that need a fixed width (combobox listbox, dropdown menu). */
22
+ max-width: min(20rem, calc(100vw - 1rem));
23
+
24
+ /* The Popover API places the element in the top layer and
25
+ defaults to `position: fixed`. Inline `left`/`top` from the
26
+ controller drive the actual placement. We declare `position:
27
+ fixed` explicitly here so authors who inspect the rule see
28
+ the contract; the browser's user-agent rules already produce
29
+ the same effect. */
30
+ position: fixed;
31
+
32
+ /* Internal padding — the popover's content uses tokens. */
33
+ padding: var(--space-2xs);
34
+ }
35
+
36
+ /* Visible-state styling via the native pseudo-class. The popover
37
+ is hidden by default (`display: none` per the user-agent) until
38
+ `:popover-open` matches; we add no additional `display` rule so
39
+ the browser default keeps working. */
40
+ .pui-popover:popover-open {
41
+ /* Surface visibility is the browser default; hook left for
42
+ per-consumer animation overrides keyed off `data-state`. */
43
+ }
44
+
45
+ .pui-popover:focus-visible {
46
+ outline: 3px solid var(--outline-clr-base);
47
+ outline-offset: 2px;
48
+ }
49
+
50
+ /* The browser does NOT render a backdrop for `popover` (unlike
51
+ `<dialog>.showModal()`). We intentionally do NOT style
52
+ `::backdrop` — popovers are non-modal by design. */
53
+ }