@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.
- package/CHANGELOG.md +64 -0
- package/CONVENTIONS.md +1499 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/components/action/button/Button.astro +95 -0
- package/components/action/button/Button.vue +92 -0
- package/components/action/button/IconButton.astro +86 -0
- package/components/action/button/IconButton.vue +85 -0
- package/components/action/button/button.css +194 -0
- package/components/action/toggle/Toggle.vue +38 -0
- package/components/action/toggle/toggle.css +77 -0
- package/components/action/toggle-group/ToggleGroup.vue +67 -0
- package/components/action/toggle-group/toggle-group.css +78 -0
- package/components/display/avatar/Avatar.astro +17 -0
- package/components/display/avatar/Avatar.vue +30 -0
- package/components/display/avatar/AvatarStack.astro +9 -0
- package/components/display/avatar/AvatarStack.vue +11 -0
- package/components/display/avatar/avatar.css +58 -0
- package/components/display/badge/Badge.astro +15 -0
- package/components/display/badge/Badge.vue +23 -0
- package/components/display/badge/badge.css +56 -0
- package/components/display/empty/Empty.astro +9 -0
- package/components/display/empty/Empty.vue +11 -0
- package/components/display/empty/empty.css +39 -0
- package/components/display/icon/Icon.astro +52 -0
- package/components/display/icon/Icon.vue +57 -0
- package/components/display/icon/icon.css +47 -0
- package/components/feedback/alert/Alert.astro +52 -0
- package/components/feedback/alert/Alert.vue +60 -0
- package/components/feedback/alert/alert.css +78 -0
- package/components/feedback/progress/Progress.astro +68 -0
- package/components/feedback/progress/Progress.vue +82 -0
- package/components/feedback/progress/progress.css +68 -0
- package/components/feedback/skeleton/Skeleton.astro +32 -0
- package/components/feedback/skeleton/Skeleton.vue +39 -0
- package/components/feedback/skeleton/skeleton.css +56 -0
- package/components/feedback/spinner/Spinner.astro +25 -0
- package/components/feedback/spinner/Spinner.vue +36 -0
- package/components/feedback/spinner/spinner.css +91 -0
- package/components/feedback/toast/Toast.astro +50 -0
- package/components/feedback/toast/Toast.vue +74 -0
- package/components/feedback/toast/toast.css +128 -0
- package/components/form/checkbox/Checkbox.astro +79 -0
- package/components/form/checkbox/Checkbox.vue +95 -0
- package/components/form/checkbox/checkbox.css +59 -0
- package/components/form/combobox/Combobox.vue +508 -0
- package/components/form/combobox/combobox.css +110 -0
- package/components/form/date-input/DateInput.astro +105 -0
- package/components/form/date-input/DateInput.vue +121 -0
- package/components/form/date-input/date-input.css +19 -0
- package/components/form/form/Form.astro +106 -0
- package/components/form/form/Form.vue +181 -0
- package/components/form/form/form.css +46 -0
- package/components/form/input-otp/InputOTP.astro +147 -0
- package/components/form/input-otp/InputOTP.vue +209 -0
- package/components/form/input-otp/input-otp.css +52 -0
- package/components/form/label/Label.astro +13 -0
- package/components/form/label/Label.vue +20 -0
- package/components/form/label/label.css +11 -0
- package/components/form/number-field/NumberField.astro +142 -0
- package/components/form/number-field/NumberField.vue +155 -0
- package/components/form/number-field/number-field.css +115 -0
- package/components/form/radio-group/RadioGroup.astro +105 -0
- package/components/form/radio-group/RadioGroup.vue +110 -0
- package/components/form/radio-group/radio-group.css +114 -0
- package/components/form/radio-group/types.ts +14 -0
- package/components/form/select/Segmented.vue +36 -0
- package/components/form/select/Select.astro +105 -0
- package/components/form/select/Select.vue +109 -0
- package/components/form/select/select.css +96 -0
- package/components/form/slider/Slider.astro +205 -0
- package/components/form/slider/Slider.vue +321 -0
- package/components/form/slider/slider.css +115 -0
- package/components/form/switch/Switch.astro +75 -0
- package/components/form/switch/Switch.vue +89 -0
- package/components/form/switch/switch.css +64 -0
- package/components/form/tags-input/TagsInput.astro +153 -0
- package/components/form/tags-input/TagsInput.vue +207 -0
- package/components/form/tags-input/tags-input.css +128 -0
- package/components/form/text-input/TextInput.astro +84 -0
- package/components/form/text-input/TextInput.vue +99 -0
- package/components/form/text-input/text-input.css +165 -0
- package/components/form/textarea/Textarea.astro +86 -0
- package/components/form/textarea/Textarea.vue +102 -0
- package/components/form/textarea/textarea.css +25 -0
- package/components/layout/accordion/Accordion.vue +59 -0
- package/components/layout/accordion/accordion.css +87 -0
- package/components/layout/card/Card.astro +13 -0
- package/components/layout/card/Card.vue +20 -0
- package/components/layout/card/card.css +55 -0
- package/components/layout/collapsible/Collapsible.vue +77 -0
- package/components/layout/collapsible/collapsible.css +76 -0
- package/components/layout/separator/Separator.astro +31 -0
- package/components/layout/separator/Separator.vue +33 -0
- package/components/layout/separator/separator.css +27 -0
- package/components/layout/table/DataTable.vue +127 -0
- package/components/layout/table/Table.astro +116 -0
- package/components/layout/table/Table.vue +146 -0
- package/components/layout/table/TableRow.vue +59 -0
- package/components/layout/table/table.css +201 -0
- package/components/layout/table/types.ts +35 -0
- package/components/layout/table/useTable.ts +7 -0
- package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
- package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
- package/components/navigation/breadcrumb/breadcrumb.css +37 -0
- package/components/navigation/navbar/Navbar.astro +62 -0
- package/components/navigation/navbar/Navbar.vue +50 -0
- package/components/navigation/navbar/navbar.css +77 -0
- package/components/navigation/pagination/Pagination.vue +107 -0
- package/components/navigation/pagination/pagination.css +53 -0
- package/components/navigation/sidebar/Sidebar.astro +132 -0
- package/components/navigation/sidebar/Sidebar.vue +174 -0
- package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
- package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
- package/components/navigation/sidebar/sidebar.css +303 -0
- package/components/navigation/sidebar/types.ts +72 -0
- package/components/navigation/tabs/Tabs.vue +84 -0
- package/components/navigation/tabs/tabs.css +39 -0
- package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
- package/components/overlay/alert-dialog/alert-dialog.css +57 -0
- package/components/overlay/command/Command.vue +356 -0
- package/components/overlay/command/command.css +179 -0
- package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
- package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
- package/components/overlay/modal/Modal.astro +66 -0
- package/components/overlay/modal/Modal.vue +85 -0
- package/components/overlay/modal/modal.css +60 -0
- package/components/overlay/popover/Popover.vue +113 -0
- package/components/overlay/popover/popover.css +53 -0
- package/components/overlay/sheet/Sheet.vue +88 -0
- package/components/overlay/sheet/sheet.css +108 -0
- package/components/overlay/tooltip/Tooltip.vue +210 -0
- package/components/overlay/tooltip/tooltip.css +50 -0
- package/composables/useUrlSort.ts +48 -0
- package/icons/alert-triangle.ts +1 -0
- package/icons/arrow-down.ts +1 -0
- package/icons/arrow-up-down.ts +5 -0
- package/icons/arrow-up.ts +1 -0
- package/icons/bell.ts +1 -0
- package/icons/check.ts +1 -0
- package/icons/chevron-down.ts +1 -0
- package/icons/chevron-left.ts +1 -0
- package/icons/chevron-right.ts +1 -0
- package/icons/chevron-up-down.ts +5 -0
- package/icons/chevron-up.ts +1 -0
- package/icons/circle-alert.ts +1 -0
- package/icons/circle-check.ts +1 -0
- package/icons/clipboard.ts +1 -0
- package/icons/download.ts +1 -0
- package/icons/edit.ts +1 -0
- package/icons/external-link.ts +1 -0
- package/icons/eye.ts +1 -0
- package/icons/file.ts +1 -0
- package/icons/filter.ts +1 -0
- package/icons/folder.ts +1 -0
- package/icons/image.ts +1 -0
- package/icons/inbox.ts +1 -0
- package/icons/index.ts +91 -0
- package/icons/info.ts +1 -0
- package/icons/layers.ts +1 -0
- package/icons/link-2.ts +1 -0
- package/icons/list.ts +1 -0
- package/icons/loader.ts +3 -0
- package/icons/menu.ts +1 -0
- package/icons/more-horizontal.ts +1 -0
- package/icons/more-vertical.ts +1 -0
- package/icons/plus-circle.ts +1 -0
- package/icons/plus.ts +1 -0
- package/icons/save.ts +1 -0
- package/icons/search.ts +1 -0
- package/icons/send.ts +1 -0
- package/icons/settings.ts +1 -0
- package/icons/tool.ts +1 -0
- package/icons/trash-2.ts +1 -0
- package/icons/trash.ts +1 -0
- package/icons/upload-cloud.ts +1 -0
- package/icons/upload.ts +1 -0
- package/icons/x.ts +1 -0
- package/package.json +150 -0
- package/styles/0-settings/colors.css +241 -0
- package/styles/0-settings/index.css +5 -0
- package/styles/0-settings/layout.css +52 -0
- package/styles/0-settings/motion.css +11 -0
- package/styles/0-settings/spacing.css +15 -0
- package/styles/0-settings/typography.css +37 -0
- package/styles/0-utils/index.css +1 -0
- package/styles/1-reset/index.css +1 -0
- package/styles/1-reset/reset.css +26 -0
- package/styles/2-base/base.css +42 -0
- package/styles/2-base/forms.css +23 -0
- package/styles/2-base/index.css +2 -0
- package/styles/3-layout/container.css +57 -0
- package/styles/3-layout/index.css +2 -0
- package/styles/3-layout/section.css +17 -0
- package/styles/5-utilities/accessibility.css +13 -0
- package/styles/5-utilities/index.css +2 -0
- package/styles/5-utilities/text.css +5 -0
- package/styles/main.css +8 -0
- package/styles/styles.d.ts +6 -0
- package/utils/a11y/focus.ts +68 -0
- package/utils/a11y/id.ts +10 -0
- package/utils/a11y/index.ts +9 -0
- package/utils/a11y/keyboard.ts +32 -0
- package/utils/a11y/live-region.ts +36 -0
- package/utils/controllers/dialog.ts +205 -0
- package/utils/controllers/disclosure.ts +117 -0
- package/utils/controllers/form.ts +524 -0
- package/utils/controllers/index.ts +39 -0
- package/utils/controllers/menu.ts +255 -0
- package/utils/controllers/number-field.ts +103 -0
- package/utils/controllers/otp.ts +252 -0
- package/utils/controllers/popover.ts +434 -0
- package/utils/controllers/sidebar.ts +610 -0
- package/utils/controllers/slider.ts +336 -0
- package/utils/controllers/tags-input.ts +255 -0
- package/utils/controllers/toast.ts +426 -0
- package/utils/dom/index.ts +1 -0
- package/utils/dom/scroll-lock.ts +48 -0
- package/utils/index.ts +3 -0
- package/utils/sort/index.ts +3 -0
- package/utils/sort/serialize.ts +19 -0
- package/utils/sort/state.ts +11 -0
- package/utils/sort/types.ts +15 -0
- package/utils/validation/form.ts +93 -0
- package/utils/validation/index.ts +13 -0
- 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
|
+
}
|