@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,88 @@
1
+ <template>
2
+ <dialog
3
+ ref="rootRef"
4
+ v-bind="$attrs"
5
+ class="pui-sheet"
6
+ data-state="closed"
7
+ :data-side="side"
8
+ data-pui-sheet
9
+ :aria-labelledby="titleId"
10
+ >
11
+ <div class="pui-sheet__head">
12
+ <h2 :id="titleId" class="pui-sheet__title">{{ title }}</h2>
13
+ <p v-if="subtitle" class="pui-sheet__sub">{{ subtitle }}</p>
14
+ </div>
15
+ <div class="pui-sheet__body"><slot /></div>
16
+ <div v-if="$slots.footer" class="pui-sheet__foot">
17
+ <slot name="footer" />
18
+ </div>
19
+ </dialog>
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
24
+ import { generateId } from '../../../utils/a11y/id.js';
25
+ import {
26
+ mountDialog,
27
+ type DialogControl,
28
+ } from '../../../utils/controllers/dialog.js';
29
+
30
+ defineOptions({ inheritAttrs: false });
31
+
32
+ const props = withDefaults(
33
+ defineProps<{
34
+ open: boolean;
35
+ title: string;
36
+ subtitle?: string;
37
+ side?: 'left' | 'right' | 'top' | 'bottom';
38
+ persistent?: boolean;
39
+ }>(),
40
+ {
41
+ subtitle: undefined,
42
+ side: 'right',
43
+ persistent: false,
44
+ },
45
+ );
46
+
47
+ const emit = defineEmits<{
48
+ 'update:open': [value: boolean];
49
+ close: [];
50
+ }>();
51
+
52
+ const rootRef = ref<HTMLDialogElement | null>(null);
53
+ const titleId = generateId('sheet-title');
54
+ let control: DialogControl | null = null;
55
+
56
+ function requestClose(): void {
57
+ emit('update:open', false);
58
+ emit('close');
59
+ }
60
+
61
+ onMounted(() => {
62
+ if (!rootRef.value) return;
63
+ control = mountDialog(rootRef.value, {
64
+ role: 'dialog',
65
+ persistent: props.persistent,
66
+ onClose: requestClose,
67
+ });
68
+ if (props.open) control.open();
69
+ });
70
+
71
+ watch(
72
+ () => props.open,
73
+ (isOpen) => {
74
+ if (!control) return;
75
+ if (isOpen) control.open();
76
+ else control.close();
77
+ },
78
+ );
79
+
80
+ onBeforeUnmount(() => {
81
+ control?.teardown();
82
+ control = null;
83
+ });
84
+ </script>
85
+
86
+ <style>
87
+ @import './sheet.css';
88
+ </style>
@@ -0,0 +1,108 @@
1
+ @layer components {
2
+ .pui-sheet {
3
+ /* Reset native <dialog> chrome — the side-anchored placement supplies
4
+ its own positioning per [data-side]. */
5
+ margin: 0;
6
+ padding: 0;
7
+ border: 0;
8
+ inset: auto;
9
+
10
+ /* Surface */
11
+ background: var(--bg-clr-surface);
12
+ box-shadow: var(--shadow-spark-lg);
13
+
14
+ /* Side-anchored positioning is set per [data-side] below. The base
15
+ rule keeps display off — the browser default for
16
+ <dialog>:not([open]) is display: none — preserve that by NOT
17
+ declaring display in the base rule. The data-state="closed"
18
+ attribute is preserved as a CSS hook for future transition work. */
19
+ position: fixed;
20
+ }
21
+
22
+ /* Right side (default): anchored to right edge, full height, fixed width. */
23
+ .pui-sheet[data-side='right'] {
24
+ top: 0;
25
+ bottom: 0;
26
+ right: 0;
27
+ width: min(28rem, 100vw - var(--space-l));
28
+ max-width: 100vw;
29
+ border-inline-start: var(--stroke-md) solid var(--border-clr-strong);
30
+ }
31
+
32
+ /* Left side: anchored to left edge, full height, fixed width. */
33
+ .pui-sheet[data-side='left'] {
34
+ top: 0;
35
+ bottom: 0;
36
+ left: 0;
37
+ width: min(28rem, 100vw - var(--space-l));
38
+ max-width: 100vw;
39
+ border-inline-end: var(--stroke-md) solid var(--border-clr-strong);
40
+ }
41
+
42
+ /* Top side: anchored to top edge, full width, fixed height. */
43
+ .pui-sheet[data-side='top'] {
44
+ top: 0;
45
+ left: 0;
46
+ right: 0;
47
+ height: min(20rem, 100vh - var(--space-l));
48
+ max-height: 100vh;
49
+ border-block-end: var(--stroke-md) solid var(--border-clr-strong);
50
+ }
51
+
52
+ /* Bottom side: anchored to bottom edge, full width, fixed height. */
53
+ .pui-sheet[data-side='bottom'] {
54
+ bottom: 0;
55
+ left: 0;
56
+ right: 0;
57
+ height: min(20rem, 100vh - var(--space-l));
58
+ max-height: 100vh;
59
+ border-block-start: var(--stroke-md) solid var(--border-clr-strong);
60
+ }
61
+
62
+ /* Apply layout-driving display only when the sheet is actually open.
63
+ Same [open] guard pattern as Modal — preserves the browser default
64
+ of display: none for closed <dialog>s. */
65
+ .pui-sheet[open] {
66
+ display: grid;
67
+ grid-template-rows: auto 1fr auto;
68
+ }
69
+
70
+ .pui-sheet::backdrop {
71
+ background: hsl(from var(--border-clr-strong) h s l / 0.5);
72
+ }
73
+
74
+ .pui-sheet:focus-visible {
75
+ outline: 3px solid var(--outline-clr-base);
76
+ outline-offset: 2px;
77
+ }
78
+
79
+ .pui-sheet__head {
80
+ padding: var(--space-m) var(--space-m) 0;
81
+ display: grid;
82
+ gap: var(--space-2xs);
83
+ }
84
+ .pui-sheet__title {
85
+ font-family: var(--ff-display);
86
+ font-weight: var(--fw-display);
87
+ letter-spacing: var(--ls-display);
88
+ font-size: var(--step-1);
89
+ margin: 0;
90
+ }
91
+ .pui-sheet__sub {
92
+ color: var(--text-clr-muted);
93
+ margin: 0;
94
+ font-size: var(--step--1);
95
+ }
96
+ .pui-sheet__body {
97
+ padding: var(--space-s) var(--space-m);
98
+ overflow-y: auto;
99
+ }
100
+ .pui-sheet__foot {
101
+ padding: var(--space-xs) var(--space-m) var(--space-m);
102
+ display: flex;
103
+ gap: var(--space-2xs);
104
+ justify-content: flex-end;
105
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
106
+ margin-block-start: var(--space-2xs);
107
+ }
108
+ }
@@ -0,0 +1,210 @@
1
+ <template>
2
+ <span ref="wrapRef" class="pui-tooltip-wrap">
3
+ <slot />
4
+ <span
5
+ :id="tooltipId"
6
+ ref="tooltipRef"
7
+ class="pui-tooltip"
8
+ role="tooltip"
9
+ >{{ label }}</span
10
+ >
11
+ </span>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { ref, onMounted, onBeforeUnmount } from 'vue';
16
+ import {
17
+ mountPopover,
18
+ type PopoverControl,
19
+ type Placement,
20
+ } from '../../../utils/controllers/popover.js';
21
+ import { generateId } from '../../../utils/a11y/id.js';
22
+
23
+ const props = withDefaults(
24
+ defineProps<{
25
+ /**
26
+ * Tooltip text content. Plain string by design — tooltips
27
+ * announce a short, descriptive label for the focused trigger
28
+ * (`aria-describedby` on the trigger points at this surface).
29
+ * Rich content (multiple paragraphs, interactive elements,
30
+ * headings) belongs in `<Popover>`; tooltips are a
31
+ * descriptive, non-interactive layer.
32
+ */
33
+ label: string;
34
+ /**
35
+ * Initial placement; the controller flips automatically on
36
+ * viewport collision. Default: `'top'` (the WAI-ARIA tooltip
37
+ * convention places the tooltip above the trigger by default).
38
+ */
39
+ placement?: Placement;
40
+ /**
41
+ * Hover-open delay in ms — the trigger must be hovered for at
42
+ * least this long before the tooltip opens. Mitigates accidental
43
+ * hover (cursor crossing the trigger en route to elsewhere).
44
+ * Default: `200`.
45
+ */
46
+ openDelay?: number;
47
+ /**
48
+ * Hover-close delay in ms — when the cursor leaves the trigger
49
+ * (or the tooltip itself), the tooltip stays open this long
50
+ * before closing. Lets the user move the cursor onto the
51
+ * tooltip surface to read longer content. Default: `100`.
52
+ */
53
+ closeDelay?: number;
54
+ }>(),
55
+ {
56
+ placement: 'top',
57
+ openDelay: 200,
58
+ closeDelay: 100,
59
+ },
60
+ );
61
+
62
+ const wrapRef = ref<HTMLElement | null>(null);
63
+ const tooltipRef = ref<HTMLElement | null>(null);
64
+ // `generateId` runs at script-setup execution time — safe in SSR/hydration
65
+ // because the same id is produced in the same order on each render path.
66
+ const tooltipId = generateId('tooltip');
67
+ let control: PopoverControl | null = null;
68
+ let openTimer: number | null = null;
69
+ let closeTimer: number | null = null;
70
+ let trigger: HTMLElement | null = null;
71
+
72
+ function clearTimers(): void {
73
+ if (openTimer != null) {
74
+ clearTimeout(openTimer);
75
+ openTimer = null;
76
+ }
77
+ if (closeTimer != null) {
78
+ clearTimeout(closeTimer);
79
+ closeTimer = null;
80
+ }
81
+ }
82
+
83
+ function scheduleOpen(): void {
84
+ clearTimers();
85
+ openTimer = window.setTimeout(() => {
86
+ control?.show();
87
+ openTimer = null;
88
+ }, props.openDelay);
89
+ }
90
+
91
+ function scheduleClose(): void {
92
+ clearTimers();
93
+ closeTimer = window.setTimeout(() => {
94
+ control?.hide();
95
+ closeTimer = null;
96
+ }, props.closeDelay);
97
+ }
98
+
99
+ function onPointerEnter(): void {
100
+ scheduleOpen();
101
+ }
102
+
103
+ function onPointerLeave(): void {
104
+ scheduleClose();
105
+ }
106
+
107
+ // Focus shows the tooltip immediately — per WAI-ARIA's tooltip
108
+ // pattern the descriptive content must be available without delay
109
+ // when the trigger receives keyboard focus. Hover-intent delays
110
+ // are a mouse-only affordance.
111
+ function onFocus(): void {
112
+ clearTimers();
113
+ control?.show();
114
+ }
115
+
116
+ function onBlur(): void {
117
+ clearTimers();
118
+ control?.hide();
119
+ }
120
+
121
+ function onTooltipPointerEnter(): void {
122
+ // Cursor entered the tooltip surface — cancel any pending close
123
+ // so the user can read longer content / interact with links if
124
+ // future tooltip variants ship with embedded links.
125
+ clearTimers();
126
+ }
127
+
128
+ function onTooltipPointerLeave(): void {
129
+ scheduleClose();
130
+ }
131
+
132
+ onMounted(() => {
133
+ if (!wrapRef.value || !tooltipRef.value) return;
134
+
135
+ // Find the first element child that isn't the tooltip itself —
136
+ // this is the trigger. `display: contents` on the wrap means
137
+ // the wrapper has no box; the slot's first element is the
138
+ // accessible trigger surface.
139
+ const tooltipEl = tooltipRef.value;
140
+ let candidate: Element | null = wrapRef.value.firstElementChild;
141
+ while (candidate && candidate === tooltipEl) {
142
+ candidate = candidate.nextElementSibling;
143
+ }
144
+ trigger = candidate as HTMLElement | null;
145
+ if (!trigger) return;
146
+
147
+ // Append our id to any existing `aria-describedby` (idempotent
148
+ // tokenized list — tooltip ids stack alongside form-error ids
149
+ // so a single trigger can be described by both a tooltip and
150
+ // a field-hint).
151
+ const existing = trigger.getAttribute('aria-describedby');
152
+ trigger.setAttribute(
153
+ 'aria-describedby',
154
+ existing ? `${existing} ${tooltipId}` : tooltipId,
155
+ );
156
+
157
+ control = mountPopover(tooltipEl, {
158
+ anchor: trigger,
159
+ placement: props.placement,
160
+ offset: 6,
161
+ modal: false,
162
+ });
163
+
164
+ trigger.addEventListener('pointerenter', onPointerEnter);
165
+ trigger.addEventListener('pointerleave', onPointerLeave);
166
+ trigger.addEventListener('focus', onFocus);
167
+ trigger.addEventListener('blur', onBlur);
168
+
169
+ tooltipEl.addEventListener('pointerenter', onTooltipPointerEnter);
170
+ tooltipEl.addEventListener('pointerleave', onTooltipPointerLeave);
171
+ });
172
+
173
+ onBeforeUnmount(() => {
174
+ clearTimers();
175
+ if (trigger) {
176
+ trigger.removeEventListener('pointerenter', onPointerEnter);
177
+ trigger.removeEventListener('pointerleave', onPointerLeave);
178
+ trigger.removeEventListener('focus', onFocus);
179
+ trigger.removeEventListener('blur', onBlur);
180
+ // Subtract our id from the tokenized list rather than blowing
181
+ // away whatever the consumer (or other Tooltips) added.
182
+ const existing = trigger.getAttribute('aria-describedby');
183
+ if (existing) {
184
+ const cleaned = existing
185
+ .split(/\s+/)
186
+ .filter((id) => id && id !== tooltipId)
187
+ .join(' ');
188
+ if (cleaned) trigger.setAttribute('aria-describedby', cleaned);
189
+ else trigger.removeAttribute('aria-describedby');
190
+ }
191
+ }
192
+ if (tooltipRef.value) {
193
+ tooltipRef.value.removeEventListener(
194
+ 'pointerenter',
195
+ onTooltipPointerEnter,
196
+ );
197
+ tooltipRef.value.removeEventListener(
198
+ 'pointerleave',
199
+ onTooltipPointerLeave,
200
+ );
201
+ }
202
+ control?.teardown();
203
+ control = null;
204
+ trigger = null;
205
+ });
206
+ </script>
207
+
208
+ <style>
209
+ @import './tooltip.css';
210
+ </style>
@@ -0,0 +1,50 @@
1
+ @layer components {
2
+ .pui-tooltip-wrap {
3
+ /* Inline wrapper — `display: contents` removes the wrapper
4
+ box so the trigger element occupies the same place in the
5
+ layout it would without the Tooltip wrapper. The wrap is
6
+ still a real DOM node (so `wrapRef.firstElementChild` finds
7
+ the trigger) but contributes no box of its own. */
8
+ display: contents;
9
+ }
10
+
11
+ .pui-tooltip {
12
+ /* Reset native popover defaults — the user-agent centers
13
+ a popover at the viewport origin via inset/margin/padding
14
+ defaults that interfere with anchor positioning. */
15
+ margin: 0;
16
+ border: 0;
17
+ inset: auto;
18
+
19
+ /* Tooltip surface — small, contrast-bumped, dark-background
20
+ affordance. Distinct from <Popover>'s surface tokens to
21
+ signal "this is descriptive metadata, not interactive
22
+ content". */
23
+ padding: var(--space-2xs) var(--space-xs);
24
+ background: var(--bg-clr-dark);
25
+ color: var(--text-clr-inverse);
26
+ border-radius: var(--radius-xs);
27
+ font-size: var(--step--2);
28
+ line-height: 1.4;
29
+ box-shadow: var(--shadow-spark);
30
+ max-width: 14rem;
31
+
32
+ /* Position is set by the controller via inline `left`/`top`.
33
+ `position: fixed` is declared explicitly for self-doc — the
34
+ Popover API's top-layer rendering already produces the same
35
+ effect via the user-agent rule. */
36
+ position: fixed;
37
+
38
+ /* Allow hovering over the tooltip surface to keep it open
39
+ (the component's `onTooltipPointerEnter` cancels the close
40
+ timer). The user-agent default for popovers is
41
+ `pointer-events: auto` once visible; we re-declare to make
42
+ the contract explicit. */
43
+ pointer-events: auto;
44
+ }
45
+
46
+ /* Tooltips are NOT focus targets — the trigger keeps focus and
47
+ the tooltip describes it via `aria-describedby`. We do not
48
+ style `:focus-visible` on `.pui-tooltip` because the surface
49
+ should never receive focus. */
50
+ }
@@ -0,0 +1,48 @@
1
+ import { computed, type ComputedRef } from 'vue';
2
+ import { useRoute, useRouter } from 'vue-router';
3
+ import {
4
+ formatSort,
5
+ parseSort,
6
+ toggleSort,
7
+ type SortState,
8
+ } from '../utils/sort/index.js';
9
+
10
+ export interface UrlSort {
11
+ /** Current sort, derived from the query string. Bind to `:sort`. */
12
+ sort: ComputedRef<SortState>;
13
+ /** Bind to `@sort`. Applies the standard cycle to the activated column and writes the URL. */
14
+ onSort: (key: string) => void;
15
+ }
16
+
17
+ /**
18
+ * Keeps a `Table`/`DataTable` sort in the URL query, the single source of truth, so a
19
+ * shared link or a reload renders the right header. Serialized as `?sort=title` /
20
+ * `?sort=-title`; ordering the rows stays the consumer's job (ADR 0005). `onSort` bakes
21
+ * in the asc → desc → unsorted cycle. Requires the optional `vue-router` peer dependency.
22
+ *
23
+ * @param param - query parameter name; defaults to `'sort'`.
24
+ * @param mode - `'replace'` (default) keeps sorting out of the back stack.
25
+ */
26
+ export function useUrlSort(
27
+ param = 'sort',
28
+ mode: 'replace' | 'push' = 'replace',
29
+ ): UrlSort {
30
+ const route = useRoute();
31
+ const router = useRouter();
32
+
33
+ const sort = computed<SortState>(() => {
34
+ const raw = route.query[param];
35
+ return parseSort(Array.isArray(raw) ? raw[0] : raw);
36
+ });
37
+
38
+ function onSort(key: string) {
39
+ const query = { ...route.query };
40
+ const value = formatSort(toggleSort(sort.value, key));
41
+ // an unsorted state drops the parameter rather than writing an empty one
42
+ if (value) query[param] = value;
43
+ else delete query[param];
44
+ router[mode]({ query });
45
+ }
46
+
47
+ return { sort, onSort };
48
+ }
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3M12 9v4m0 4h.01"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M12 5v14m7-7-7 7-7-7"/></svg>';
@@ -0,0 +1,5 @@
1
+ // Two-tone capable: the up and down arrows stay separate paths so each can be coloured
2
+ // independently via --pui-icon-clr-up / --pui-icon-clr-down (both falling back to the
3
+ // shared --pui-icon-clr, then currentColor). Keep them split — merging the subpaths into
4
+ // a single `d` collapses the two tones back into one.
5
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path stroke="var(--pui-icon-clr-up, var(--pui-icon-clr, currentColor))" d="m3 8 4-4 4 4M7 4v16"/><path stroke="var(--pui-icon-clr-down, var(--pui-icon-clr, currentColor))" d="m21 16-4 4-4-4m4 4V4"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="m5 12 7-7 7 7m-7 7V5"/></svg>';
package/icons/bell.ts ADDED
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9m-4.27 13a2 2 0 0 1-3.46 0"/></svg>';
package/icons/check.ts ADDED
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>';
@@ -0,0 +1,5 @@
1
+ // Two-tone capable: the up and down chevrons stay separate paths so each can be coloured
2
+ // independently via --pui-icon-clr-up / --pui-icon-clr-down (both falling back to the
3
+ // shared --pui-icon-clr, then currentColor). Keep them split — merging the subpaths into
4
+ // a single `d` collapses the two tones back into one.
5
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path stroke="var(--pui-icon-clr-up, var(--pui-icon-clr, currentColor))" d="m18 10-6-6-6 6"/><path stroke="var(--pui-icon-clr-down, var(--pui-icon-clr, currentColor))" d="m6 14 6 6 6-6"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4m4-5 5 5 5-5m-5 5V3"/></svg>';
package/icons/edit.ts ADDED
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6m4-3h6v6m-11 5L21 3"/></svg>';
package/icons/eye.ts ADDED
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>';
package/icons/file.ts ADDED
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M13 2v7h7"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
package/icons/image.ts ADDED
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
package/icons/inbox.ts ADDED
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11"/></svg>';