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