@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,117 @@
1
+ <template>
2
+ <dialog
3
+ ref="rootRef"
4
+ v-bind="$attrs"
5
+ class="pui-alert-dialog"
6
+ data-state="closed"
7
+ data-pui-alert-dialog
8
+ :aria-labelledby="titleId"
9
+ :aria-describedby="descriptionId"
10
+ >
11
+ <div class="pui-alert-dialog__head">
12
+ <h2 :id="titleId" class="pui-alert-dialog__title">{{ title }}</h2>
13
+ <p :id="descriptionId" class="pui-alert-dialog__description">
14
+ {{ description }}
15
+ </p>
16
+ </div>
17
+ <div class="pui-alert-dialog__actions">
18
+ <button
19
+ type="button"
20
+ class="pui-btn"
21
+ data-size="md"
22
+ data-dialog-cancel
23
+ @click="onCancel"
24
+ >
25
+ {{ cancelLabel }}
26
+ </button>
27
+ <button
28
+ type="button"
29
+ class="pui-btn"
30
+ :data-variant="destructive ? 'danger' : 'primary'"
31
+ data-size="md"
32
+ @click="onConfirm"
33
+ >
34
+ {{ confirmLabel }}
35
+ </button>
36
+ </div>
37
+ </dialog>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
42
+ import { generateId } from '../../../utils/a11y/id.js';
43
+ import {
44
+ mountDialog,
45
+ type DialogControl,
46
+ } from '../../../utils/controllers/dialog.js';
47
+
48
+ defineOptions({ inheritAttrs: false });
49
+
50
+ const props = withDefaults(
51
+ defineProps<{
52
+ open: boolean;
53
+ title: string;
54
+ description: string;
55
+ confirmLabel?: string;
56
+ cancelLabel?: string;
57
+ destructive?: boolean;
58
+ }>(),
59
+ {
60
+ confirmLabel: 'Confirm',
61
+ cancelLabel: 'Cancel',
62
+ destructive: false,
63
+ },
64
+ );
65
+
66
+ const emit = defineEmits<{
67
+ 'update:open': [value: boolean];
68
+ confirm: [];
69
+ cancel: [];
70
+ }>();
71
+
72
+ const rootRef = ref<HTMLDialogElement | null>(null);
73
+ const titleId = generateId('alertdialog-title');
74
+ const descriptionId = generateId('alertdialog-desc');
75
+ let control: DialogControl | null = null;
76
+
77
+ function onConfirm(): void {
78
+ emit('confirm');
79
+ emit('update:open', false);
80
+ }
81
+
82
+ function onCancel(): void {
83
+ emit('cancel');
84
+ emit('update:open', false);
85
+ }
86
+
87
+ onMounted(() => {
88
+ if (!rootRef.value) return;
89
+ control = mountDialog(rootRef.value, {
90
+ role: 'alertdialog',
91
+ persistent: true,
92
+ initialFocus: 'cancel',
93
+ // No onClose — persistent dialogs suppress Escape and backdrop-click
94
+ // dismissal at the controller layer. Close happens only through the
95
+ // explicit confirm/cancel buttons wired in the template.
96
+ });
97
+ if (props.open) control.open();
98
+ });
99
+
100
+ watch(
101
+ () => props.open,
102
+ (isOpen) => {
103
+ if (!control) return;
104
+ if (isOpen) control.open();
105
+ else control.close();
106
+ },
107
+ );
108
+
109
+ onBeforeUnmount(() => {
110
+ control?.teardown();
111
+ control = null;
112
+ });
113
+ </script>
114
+
115
+ <style>
116
+ @import './alert-dialog.css';
117
+ </style>
@@ -0,0 +1,57 @@
1
+ @layer components {
2
+ .pui-alert-dialog {
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 — mirrors Modal but tightened for confirm dialogs. */
10
+ background: var(--bg-clr-surface);
11
+ border-radius: var(--radius-md);
12
+ box-shadow: var(--shadow-spark-lg);
13
+
14
+ /* Layout — alert dialogs are typically narrower than full Modals. */
15
+ max-width: 24rem;
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-alert-dialog[open] {
23
+ display: grid;
24
+ }
25
+ .pui-alert-dialog::backdrop {
26
+ background: hsl(from var(--border-clr-strong) h s l / 0.5);
27
+ }
28
+ .pui-alert-dialog:focus-visible {
29
+ outline: 3px solid var(--outline-clr-base);
30
+ outline-offset: 2px;
31
+ }
32
+ .pui-alert-dialog__head {
33
+ padding: var(--space-m) var(--space-m) 0;
34
+ display: grid;
35
+ gap: var(--space-2xs);
36
+ }
37
+ .pui-alert-dialog__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-alert-dialog__description {
45
+ color: var(--text-clr-muted);
46
+ margin: 0;
47
+ font-size: var(--step--1);
48
+ }
49
+ .pui-alert-dialog__actions {
50
+ padding: var(--space-s) var(--space-m) var(--space-m);
51
+ display: flex;
52
+ gap: var(--space-2xs);
53
+ justify-content: flex-end;
54
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
55
+ margin-block-start: var(--space-s);
56
+ }
57
+ }
@@ -0,0 +1,356 @@
1
+ <template>
2
+ <dialog
3
+ ref="dialogRef"
4
+ v-bind="$attrs"
5
+ class="pui-command"
6
+ data-state="closed"
7
+ :aria-labelledby="titleId"
8
+ >
9
+ <h2 :id="titleId" class="pui-command__title">{{ title }}</h2>
10
+ <div class="pui-command__inputwrap">
11
+ <Icon name="search" size="sm" class="pui-command__searchicon" />
12
+ <input
13
+ :id="inputId"
14
+ ref="inputRef"
15
+ class="pui-command__input"
16
+ type="text"
17
+ role="combobox"
18
+ :value="query"
19
+ :placeholder="placeholder"
20
+ :aria-expanded="commandsOpen ? 'true' : 'false'"
21
+ :aria-controls="listboxId"
22
+ :aria-activedescendant="activeOptionDomId || undefined"
23
+ aria-autocomplete="list"
24
+ @input="onInput"
25
+ @keydown="onKeydown"
26
+ />
27
+ </div>
28
+ <ul
29
+ :id="listboxId"
30
+ ref="listboxRef"
31
+ class="pui-command__listbox"
32
+ role="listbox"
33
+ :aria-label="title"
34
+ >
35
+ <!--
36
+ ARIA APG combobox/listbox pattern: keyboard interaction is owned
37
+ by the <input role="combobox"> above via aria-activedescendant
38
+ (Arrow/Home/End/Enter handled in onKeydown), so the option rows
39
+ are intentionally NOT focusable (no tabindex) and carry no per-row
40
+ key handlers. The @click / @mouseenter here are pointer affordances
41
+ that mirror the already-complete keyboard path. These three rules
42
+ assume a self-contained interactive element and false-positive on
43
+ this pattern; the keyboard behaviour they ask for already exists at
44
+ the combobox level. See W3C WAI-ARIA APG "Combobox".
45
+ -->
46
+ <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/interactive-supports-focus, vuejs-accessibility/mouse-events-have-key-events -->
47
+ <li
48
+ v-for="(cmd, idx) in filteredCommands"
49
+ :id="`${listboxId}-opt-${idx}`"
50
+ :key="cmd.id"
51
+ class="pui-command__option"
52
+ role="option"
53
+ :aria-selected="activeIndex === idx ? 'true' : 'false'"
54
+ :aria-disabled="cmd.disabled ? 'true' : undefined"
55
+ :data-active="activeIndex === idx ? 'true' : undefined"
56
+ :data-disabled="cmd.disabled ? 'true' : undefined"
57
+ @mousedown.prevent
58
+ @click="onOptionClick(cmd)"
59
+ @mouseenter="onOptionMouseEnter(idx)"
60
+ >
61
+ <span
62
+ v-if="cmd.icon"
63
+ class="pui-command__icon"
64
+ aria-hidden="true"
65
+ >
66
+ <Icon :name="cmd.icon" size="sm" />
67
+ </span>
68
+ <span class="pui-command__label">{{ cmd.label }}</span>
69
+ <kbd v-if="cmd.shortcut" class="pui-command__kbd">{{
70
+ cmd.shortcut
71
+ }}</kbd>
72
+ </li>
73
+ <li
74
+ v-if="filteredCommands.length === 0"
75
+ class="pui-command__empty"
76
+ role="presentation"
77
+ >
78
+ {{ emptyMessage }}
79
+ </li>
80
+ </ul>
81
+ </dialog>
82
+ </template>
83
+
84
+ <script setup lang="ts">
85
+ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
86
+ import {
87
+ mountDialog,
88
+ type DialogControl,
89
+ } from '../../../utils/controllers/dialog.js';
90
+ import { generateId } from '../../../utils/a11y/id.js';
91
+ import Icon from '../../display/icon/Icon.vue';
92
+ import type { IconName } from '../../../icons/index.js';
93
+
94
+ defineOptions({ inheritAttrs: false });
95
+
96
+ interface CommandItem {
97
+ id: string;
98
+ label: string;
99
+ icon?: IconName;
100
+ shortcut?: string;
101
+ disabled?: boolean;
102
+ onSelect: () => void;
103
+ }
104
+
105
+ const props = withDefaults(
106
+ defineProps<{
107
+ /**
108
+ * Controlled open state (paired via `v-model:open`). The parent
109
+ * owns the boolean; the component reflects it onto the native
110
+ * `<dialog>.showModal()` lifecycle. Consumers wire their own
111
+ * `Cmd+K` (or other) shortcut and flip `open` from the handler.
112
+ */
113
+ open: boolean;
114
+ /**
115
+ * The list of commands to render. Each command needs a stable
116
+ * `id`, a `label`, and an `onSelect` callback fired when the
117
+ * user activates the row (click or Enter on the active option).
118
+ * Optional `icon` (token from the icon registry) and
119
+ * `shortcut` (display-only string, e.g. `"⌘K"`) decorate the
120
+ * row; `disabled` mutes the row and skips it in keyboard nav.
121
+ */
122
+ commands: CommandItem[];
123
+ /**
124
+ * Visually hidden title for the dialog. Used as the dialog's
125
+ * `aria-labelledby` target so screen readers announce the
126
+ * palette's purpose on open. Default: "Command palette".
127
+ */
128
+ title?: string;
129
+ /** Placeholder for the search input. Default: "Search commands…". */
130
+ placeholder?: string;
131
+ /**
132
+ * Empty-state message when no commands match the current query.
133
+ * Default: "No commands found".
134
+ */
135
+ emptyMessage?: string;
136
+ /**
137
+ * Optional custom filter. Default: case-insensitive substring
138
+ * match on the command label. Receives the command and the
139
+ * current query; returns `true` to keep the command visible.
140
+ */
141
+ filter?: (command: CommandItem, query: string) => boolean;
142
+ }>(),
143
+ {
144
+ title: 'Command palette',
145
+ placeholder: 'Search commands…',
146
+ emptyMessage: 'No commands found',
147
+ filter: undefined,
148
+ },
149
+ );
150
+
151
+ const emit = defineEmits<{
152
+ 'update:open': [value: boolean];
153
+ close: [];
154
+ }>();
155
+
156
+ const inputId = generateId('command-input');
157
+ const listboxId = `${inputId}-listbox`;
158
+ const titleId = generateId('command-title');
159
+
160
+ const dialogRef = ref<HTMLDialogElement | null>(null);
161
+ const inputRef = ref<HTMLInputElement | null>(null);
162
+ const listboxRef = ref<HTMLUListElement | null>(null);
163
+
164
+ const query = ref('');
165
+ const activeIndex = ref<number | null>(null);
166
+
167
+ let control: DialogControl | null = null;
168
+
169
+ // `commandsOpen` mirrors the parent's `open` for the input's
170
+ // `aria-expanded` state — when the dialog is open, the listbox
171
+ // inside is also "open" (visible), so the combobox is expanded.
172
+ const commandsOpen = computed(() => props.open);
173
+
174
+ function defaultFilter(command: CommandItem, q: string): boolean {
175
+ if (!q) return true;
176
+ return command.label.toLowerCase().includes(q.toLowerCase());
177
+ }
178
+
179
+ const filteredCommands = computed(() => {
180
+ const fn = props.filter ?? defaultFilter;
181
+ return props.commands.filter((cmd) => fn(cmd, query.value));
182
+ });
183
+
184
+ const activeOptionDomId = computed(() => {
185
+ if (activeIndex.value == null) return null;
186
+ return `${listboxId}-opt-${activeIndex.value}`;
187
+ });
188
+
189
+ function requestClose(): void {
190
+ emit('update:open', false);
191
+ emit('close');
192
+ }
193
+
194
+ function moveActive(direction: 1 | -1): void {
195
+ const len = filteredCommands.value.length;
196
+ if (len === 0) {
197
+ activeIndex.value = null;
198
+ return;
199
+ }
200
+ let idx = activeIndex.value;
201
+ for (let i = 0; i < len; i += 1) {
202
+ if (idx == null) {
203
+ idx = direction === 1 ? 0 : len - 1;
204
+ } else {
205
+ idx = (idx + direction + len) % len;
206
+ }
207
+ if (!filteredCommands.value[idx]?.disabled) {
208
+ activeIndex.value = idx;
209
+ scrollActiveIntoView();
210
+ return;
211
+ }
212
+ }
213
+ }
214
+
215
+ function scrollActiveIntoView(): void {
216
+ if (activeIndex.value == null || !listboxRef.value) return;
217
+ const el = listboxRef.value.querySelector<HTMLElement>(
218
+ `#${CSS.escape(`${listboxId}-opt-${activeIndex.value}`)}`,
219
+ );
220
+ el?.scrollIntoView({ block: 'nearest' });
221
+ }
222
+
223
+ function activateCommand(cmd: CommandItem): void {
224
+ if (cmd.disabled) return;
225
+ // Close the palette FIRST so focus returns to the original
226
+ // trigger, then run the consumer's action. Running the action
227
+ // first risks a race where the action navigates and focus
228
+ // restoration lands somewhere unexpected.
229
+ requestClose();
230
+ // Defer to a microtask so the close transition starts before
231
+ // the action fires; for simple actions this is unobservable but
232
+ // for actions that open another dialog it gives the close path
233
+ // a clean exit.
234
+ queueMicrotask(() => cmd.onSelect());
235
+ }
236
+
237
+ function onInput(event: Event): void {
238
+ const target = event.target as HTMLInputElement;
239
+ query.value = target.value;
240
+ // Reset to first match so Enter has an obvious target.
241
+ const firstEnabled = filteredCommands.value.findIndex((o) => !o.disabled);
242
+ activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
243
+ }
244
+
245
+ function onOptionClick(cmd: CommandItem): void {
246
+ activateCommand(cmd);
247
+ }
248
+
249
+ function onOptionMouseEnter(idx: number): void {
250
+ if (filteredCommands.value[idx]?.disabled) return;
251
+ activeIndex.value = idx;
252
+ }
253
+
254
+ function onKeydown(event: KeyboardEvent): void {
255
+ switch (event.key) {
256
+ case 'ArrowDown':
257
+ event.preventDefault();
258
+ moveActive(1);
259
+ break;
260
+ case 'ArrowUp':
261
+ event.preventDefault();
262
+ moveActive(-1);
263
+ break;
264
+ case 'Home':
265
+ event.preventDefault();
266
+ {
267
+ const firstEnabled = filteredCommands.value.findIndex(
268
+ (o) => !o.disabled,
269
+ );
270
+ if (firstEnabled >= 0) {
271
+ activeIndex.value = firstEnabled;
272
+ scrollActiveIntoView();
273
+ }
274
+ }
275
+ break;
276
+ case 'End':
277
+ event.preventDefault();
278
+ {
279
+ let lastEnabled = -1;
280
+ for (
281
+ let i = filteredCommands.value.length - 1;
282
+ i >= 0;
283
+ i -= 1
284
+ ) {
285
+ if (!filteredCommands.value[i]?.disabled) {
286
+ lastEnabled = i;
287
+ break;
288
+ }
289
+ }
290
+ if (lastEnabled >= 0) {
291
+ activeIndex.value = lastEnabled;
292
+ scrollActiveIntoView();
293
+ }
294
+ }
295
+ break;
296
+ case 'Enter':
297
+ if (activeIndex.value == null) return;
298
+ event.preventDefault();
299
+ {
300
+ const cmd = filteredCommands.value[activeIndex.value];
301
+ if (cmd) activateCommand(cmd);
302
+ }
303
+ break;
304
+ // Escape is handled by the native <dialog>'s `cancel` event
305
+ // via the dialog controller's `onClose`. No need to wire it
306
+ // here — that would double-fire close.
307
+ default:
308
+ break;
309
+ }
310
+ }
311
+
312
+ function resetState(): void {
313
+ query.value = '';
314
+ // Initial active row is the first enabled command so a single
315
+ // Enter immediately after opening fires the most likely action.
316
+ const firstEnabled = props.commands.findIndex((c) => !c.disabled);
317
+ activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
318
+ }
319
+
320
+ onMounted(() => {
321
+ if (!dialogRef.value) return;
322
+ control = mountDialog(dialogRef.value, {
323
+ role: 'dialog',
324
+ onClose: requestClose,
325
+ });
326
+ if (props.open) {
327
+ resetState();
328
+ control.open();
329
+ }
330
+ });
331
+
332
+ watch(
333
+ () => props.open,
334
+ (isOpen) => {
335
+ if (!control) return;
336
+ if (isOpen) {
337
+ // Reset state on every open so the palette always starts
338
+ // fresh — the canonical Cmd+K UX. Prior query / cursor
339
+ // position are not preserved between sessions.
340
+ resetState();
341
+ control.open();
342
+ } else {
343
+ control.close();
344
+ }
345
+ },
346
+ );
347
+
348
+ onBeforeUnmount(() => {
349
+ control?.teardown();
350
+ control = null;
351
+ });
352
+ </script>
353
+
354
+ <style>
355
+ @import './command.css';
356
+ </style>
@@ -0,0 +1,179 @@
1
+ @layer components {
2
+ /* ===== pui-command =====
3
+ *
4
+ * Search palette built on the native `<dialog>` element via
5
+ * `mountDialog`. Reuses the combobox-pattern input + listbox
6
+ * inside the dialog chrome (search field at the top, scrollable
7
+ * commands below). The dialog provides top-layer rendering, focus
8
+ * trapping, and Escape dismissal; the controller layers on
9
+ * scroll-lock and backdrop-click handling.
10
+ *
11
+ * Distinct from Combobox CSS: Command does NOT use the `.pui-field`
12
+ * form-primitive scaffold (no label/hint/errors — the palette is
13
+ * an action launcher, not a form control). The input chrome is
14
+ * minimal (no border, just a leading search icon) since the
15
+ * dialog's own border supplies the visual frame.
16
+ */
17
+
18
+ .pui-command {
19
+ /* Reset native <dialog> chrome — the palette supplies its own
20
+ layout. */
21
+ margin: 0;
22
+ padding: 0;
23
+ border: var(--stroke-md) solid var(--border-clr-strong);
24
+ inset: 0;
25
+
26
+ /* Surface */
27
+ background: var(--bg-clr-surface);
28
+ border-radius: var(--radius-md);
29
+ box-shadow: var(--shadow-spark-lg);
30
+ color: var(--text-clr-base);
31
+
32
+ /* Sizing — palette sits centered, generous max-width so long
33
+ command labels and shortcuts fit comfortably. The dialog
34
+ defaults to centered (margin: auto on the [open] state). */
35
+ width: calc(100% - var(--space-m) * 2);
36
+ max-width: 36rem;
37
+ max-height: min(32rem, calc(100vh - var(--space-l) * 2));
38
+
39
+ /* Center via the [open] grid layout below. */
40
+ }
41
+
42
+ .pui-command[open] {
43
+ display: grid;
44
+ grid-template-rows: auto 1fr;
45
+ margin: auto;
46
+ }
47
+
48
+ .pui-command::backdrop {
49
+ background: hsl(from var(--border-clr-strong) h s l / 0.5);
50
+ }
51
+
52
+ .pui-command:focus-visible {
53
+ outline: 3px solid var(--outline-clr-base);
54
+ outline-offset: 2px;
55
+ }
56
+
57
+ /* Visually-hidden title — the palette doesn't need a visible
58
+ heading since the search input + leading icon already explain
59
+ the surface, but `aria-labelledby` requires a real DOM node
60
+ with text content. */
61
+ .pui-command__title {
62
+ position: absolute;
63
+ width: 1px;
64
+ height: 1px;
65
+ padding: 0;
66
+ margin: -1px;
67
+ overflow: hidden;
68
+ clip: rect(0, 0, 0, 0);
69
+ white-space: nowrap;
70
+ border: 0;
71
+ }
72
+
73
+ .pui-command__inputwrap {
74
+ position: relative;
75
+ display: flex;
76
+ align-items: center;
77
+ padding: var(--space-2xs) var(--space-s);
78
+ border-block-end: var(--stroke-sm) solid var(--border-clr-base);
79
+ }
80
+
81
+ .pui-command__searchicon {
82
+ color: var(--text-clr-muted);
83
+ margin-inline-end: var(--space-2xs);
84
+ flex-shrink: 0;
85
+ }
86
+
87
+ .pui-command__input {
88
+ /* Borderless input — the dialog's border + the wrap's
89
+ block-end border supply the visual frame. The search icon
90
+ precedes the input so the surface reads as a search field. */
91
+ appearance: none;
92
+ border: 0;
93
+ outline: none;
94
+ background: transparent;
95
+ width: 100%;
96
+ padding: var(--space-2xs) 0;
97
+ font: inherit;
98
+ font-size: var(--step-0);
99
+ color: var(--text-clr-base);
100
+ }
101
+
102
+ .pui-command__input::placeholder {
103
+ color: var(--text-clr-muted);
104
+ }
105
+
106
+ .pui-command__listbox {
107
+ list-style: none;
108
+ margin: 0;
109
+ padding: var(--space-3xs);
110
+ overflow-y: auto;
111
+ display: flex;
112
+ flex-direction: column;
113
+ gap: 1px;
114
+ }
115
+
116
+ .pui-command__option {
117
+ appearance: none;
118
+ background: transparent;
119
+ border: 0;
120
+ text-align: start;
121
+ width: 100%;
122
+ padding: var(--space-2xs) var(--space-xs);
123
+ font: inherit;
124
+ font-size: var(--step--1);
125
+ color: var(--text-clr-base);
126
+ border-radius: var(--radius-xs);
127
+ cursor: pointer;
128
+
129
+ /* Three-column row: leading icon, label, trailing shortcut. */
130
+ display: grid;
131
+ grid-template-columns: auto 1fr auto;
132
+ align-items: center;
133
+ gap: var(--space-2xs);
134
+ }
135
+
136
+ .pui-command__option[data-active='true']:not([data-disabled='true']) {
137
+ background: var(--bg-clr-surface-2);
138
+ }
139
+
140
+ .pui-command__option[data-disabled='true'] {
141
+ color: var(--text-clr-muted);
142
+ cursor: not-allowed;
143
+ opacity: 0.6;
144
+ }
145
+
146
+ .pui-command__icon {
147
+ color: var(--text-clr-muted);
148
+ display: inline-flex;
149
+ align-items: center;
150
+ }
151
+
152
+ .pui-command__label {
153
+ /* Truncate single-line labels gracefully; long descriptions
154
+ belong in a Tooltip on the row, not in the row itself. */
155
+ overflow: hidden;
156
+ text-overflow: ellipsis;
157
+ white-space: nowrap;
158
+ }
159
+
160
+ .pui-command__kbd {
161
+ /* Display-only shortcut hint. Uses the mono-style font and a
162
+ tiny pill so the row visually reads as "label — shortcut". */
163
+ font-family: var(--ff-mono);
164
+ font-size: var(--step--2);
165
+ color: var(--text-clr-muted);
166
+ background: var(--bg-clr-surface-2);
167
+ border: var(--stroke-sm) solid var(--border-clr-base);
168
+ border-radius: var(--radius-xs);
169
+ padding: 2px var(--space-3xs);
170
+ }
171
+
172
+ .pui-command__empty {
173
+ padding: var(--space-s) var(--space-xs);
174
+ color: var(--text-clr-muted);
175
+ font-size: var(--step--1);
176
+ font-style: italic;
177
+ text-align: center;
178
+ }
179
+ }