@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,15 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--space-3xs: 0.25rem;
|
|
3
|
+
--space-2xs: 0.5rem;
|
|
4
|
+
--space-xs: 0.75rem;
|
|
5
|
+
--space-s: 1rem;
|
|
6
|
+
--space-m: 1.5rem;
|
|
7
|
+
--space-l: 2rem;
|
|
8
|
+
--space-xl: 3rem;
|
|
9
|
+
--space-2xl: 4rem;
|
|
10
|
+
--space-3xl: 6rem;
|
|
11
|
+
|
|
12
|
+
/* Layout-shell widths (used by Sidebar et al.) */
|
|
13
|
+
--space-sidebar-width: 16rem;
|
|
14
|
+
--space-sidebar-width-collapsed: 4rem;
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
/* The lower rungs are cut for dense admin surfaces: --step--1 is the size UI
|
|
3
|
+
text and controls actually run at, and --step-0 stays the reading size for
|
|
4
|
+
prose. Anything below --step--2 is for legal/meta text only. */
|
|
5
|
+
--step--3: 0.625rem; /* 10px */
|
|
6
|
+
--step--2: 0.75rem; /* 12px — badges, uppercase section labels */
|
|
7
|
+
--step--1: 0.875rem; /* 14px — body, controls, table cells */
|
|
8
|
+
--step-0: 1rem;
|
|
9
|
+
--step-1: 1.25rem;
|
|
10
|
+
--step-2: 1.5rem;
|
|
11
|
+
--step-3: 2rem;
|
|
12
|
+
--step-4: 2.5rem;
|
|
13
|
+
--step-5: 3rem;
|
|
14
|
+
--step-6: 4rem;
|
|
15
|
+
|
|
16
|
+
--ff-base:
|
|
17
|
+
system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial,
|
|
18
|
+
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
|
19
|
+
--ff-mono:
|
|
20
|
+
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
21
|
+
--fw-regular: 400;
|
|
22
|
+
--fw-semibold: 600;
|
|
23
|
+
--fw-control: 650;
|
|
24
|
+
--fw-bold: 700;
|
|
25
|
+
|
|
26
|
+
--lh-tight: 1.1;
|
|
27
|
+
--lh-base: 1.5;
|
|
28
|
+
--lh-control: 1.2;
|
|
29
|
+
|
|
30
|
+
--ff-display: var(--ff-base);
|
|
31
|
+
--fs-display: var(--step-4);
|
|
32
|
+
--fw-display: 800;
|
|
33
|
+
|
|
34
|
+
--ls-display: -0.04em;
|
|
35
|
+
--ls-eyebrow: 0.12em;
|
|
36
|
+
--ls-label: 0.04em; /* uppercase section headers — a fraction of --ls-eyebrow's spread */
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/* Reserved for shared CSS utility helpers that are not tied to one layer. */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import url('./reset.css');
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
@layer reset {
|
|
2
|
+
*,
|
|
3
|
+
*::before,
|
|
4
|
+
*::after {
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
body,
|
|
9
|
+
h1,
|
|
10
|
+
h2,
|
|
11
|
+
h3,
|
|
12
|
+
p,
|
|
13
|
+
ul,
|
|
14
|
+
ol,
|
|
15
|
+
figure,
|
|
16
|
+
blockquote {
|
|
17
|
+
margin: 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
img,
|
|
21
|
+
picture,
|
|
22
|
+
svg {
|
|
23
|
+
display: block;
|
|
24
|
+
max-width: 100%;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
@layer base {
|
|
2
|
+
html,
|
|
3
|
+
body {
|
|
4
|
+
min-height: 100%;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
font-family: var(--ff-base);
|
|
9
|
+
font-size: var(--step--1);
|
|
10
|
+
line-height: var(--lh-base);
|
|
11
|
+
color: var(--text-clr-base);
|
|
12
|
+
background: var(--bg-clr-gradient);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
h1,
|
|
16
|
+
h2,
|
|
17
|
+
h3 {
|
|
18
|
+
line-height: var(--lh-tight);
|
|
19
|
+
text-wrap: balance;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
h1 {
|
|
23
|
+
font-size: var(--step-2);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
h2 {
|
|
27
|
+
font-size: var(--step-1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
p {
|
|
31
|
+
color: var(--text-clr-muted);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
a {
|
|
35
|
+
color: var(--text-clr-brand);
|
|
36
|
+
text-underline-offset: 0.14em;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
a:hover {
|
|
40
|
+
color: var(--text-clr-brand-hover);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
@layer base {
|
|
2
|
+
label {
|
|
3
|
+
display: block;
|
|
4
|
+
font-weight: var(--fw-semibold);
|
|
5
|
+
margin-bottom: var(--space-xs);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
input,
|
|
9
|
+
textarea,
|
|
10
|
+
select {
|
|
11
|
+
width: 100%;
|
|
12
|
+
border: var(--stroke-sm) solid var(--border-clr-base);
|
|
13
|
+
border-radius: var(--radius-sm);
|
|
14
|
+
padding: var(--space-s);
|
|
15
|
+
font: inherit;
|
|
16
|
+
color: var(--text-clr-base);
|
|
17
|
+
background: var(--bg-clr-surface);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
button {
|
|
21
|
+
font: inherit;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
@layer layout {
|
|
2
|
+
.container {
|
|
3
|
+
display: grid;
|
|
4
|
+
gap: var(--gap);
|
|
5
|
+
grid-template-columns:
|
|
6
|
+
calc((100vw - var(--container-width-md)) / 2) repeat(
|
|
7
|
+
var(--grid-columns),
|
|
8
|
+
1fr
|
|
9
|
+
)
|
|
10
|
+
calc((100vw - var(--container-width-md)) / 2);
|
|
11
|
+
width: 100%;
|
|
12
|
+
|
|
13
|
+
&[data-container-width='sm'] {
|
|
14
|
+
grid-template-columns:
|
|
15
|
+
calc((100vw - var(--container-width-sm)) / 2) repeat(
|
|
16
|
+
var(--grid-columns),
|
|
17
|
+
1fr
|
|
18
|
+
)
|
|
19
|
+
calc((100vw - var(--container-width-sm)) / 2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
&[data-container-width='lg'] {
|
|
23
|
+
grid-template-columns:
|
|
24
|
+
calc((100vw - var(--container-width-lg)) / 2) repeat(
|
|
25
|
+
var(--grid-columns),
|
|
26
|
+
1fr
|
|
27
|
+
)
|
|
28
|
+
calc((100vw - var(--container-width-lg)) / 2);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.container__content {
|
|
33
|
+
grid-column: 2;
|
|
34
|
+
min-width: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.container__full-content {
|
|
38
|
+
grid-column: 1 / -1;
|
|
39
|
+
min-width: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.container__grid-content {
|
|
43
|
+
grid-column: 2;
|
|
44
|
+
display: grid;
|
|
45
|
+
grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr));
|
|
46
|
+
gap: var(--gap);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@media (max-width: 48rem) {
|
|
50
|
+
.container[data-decorator-title]::after {
|
|
51
|
+
position: static;
|
|
52
|
+
display: block;
|
|
53
|
+
margin-block-end: var(--space-s);
|
|
54
|
+
text-align: right;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
@layer layout {
|
|
2
|
+
.section {
|
|
3
|
+
padding-block: var(--space-3xl);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.section[data-spacing='small'] {
|
|
7
|
+
padding-block: var(--space-2xl);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.section[data-spacing='large'] {
|
|
11
|
+
padding-block: var(--space-3xl);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.section + .section {
|
|
15
|
+
padding-block-start: 0;
|
|
16
|
+
}
|
|
17
|
+
}
|
package/styles/main.css
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
@layer reset, base, layout, components, utilities;
|
|
2
|
+
|
|
3
|
+
@import url('./0-settings/index.css');
|
|
4
|
+
@import url('./0-utils/index.css');
|
|
5
|
+
@import url('./1-reset/index.css');
|
|
6
|
+
@import url('./2-base/index.css');
|
|
7
|
+
@import url('./3-layout/index.css');
|
|
8
|
+
@import url('./5-utilities/index.css');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Type stub for the `@pienter/ui/styles` subpath. That export is a CSS bundle
|
|
2
|
+
// imported only for its side effects (`import '@pienter/ui/styles'`). Under
|
|
3
|
+
// `moduleResolution: bundler`, TypeScript needs a declaration for the bare
|
|
4
|
+
// specifier or it raises TS2882; this empty module provides one. Wired via the
|
|
5
|
+
// `types` condition on the `./styles` export in package.json.
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface FocusTrap {
|
|
2
|
+
release: () => void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const focusableSelector =
|
|
6
|
+
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
|
+
|
|
8
|
+
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
|
9
|
+
return Array.from(
|
|
10
|
+
container.querySelectorAll<HTMLElement>(focusableSelector),
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createFocusTrap(container: HTMLElement): FocusTrap {
|
|
15
|
+
function handleKeyDown(event: KeyboardEvent): void {
|
|
16
|
+
if (event.key !== 'Tab') return;
|
|
17
|
+
|
|
18
|
+
const focusable = getFocusableElements(container);
|
|
19
|
+
if (focusable.length === 0) return;
|
|
20
|
+
|
|
21
|
+
const first = focusable[0];
|
|
22
|
+
const last = focusable[focusable.length - 1];
|
|
23
|
+
if (!first || !last) return;
|
|
24
|
+
|
|
25
|
+
if (event.shiftKey) {
|
|
26
|
+
if (document.activeElement === first) {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
last.focus();
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
if (document.activeElement === last) {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
first.focus();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
release() {
|
|
42
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function focusFirstInvalid(formEl: HTMLElement): boolean {
|
|
48
|
+
const firstInvalid = formEl.querySelector<HTMLElement>(
|
|
49
|
+
'[aria-invalid="true"]',
|
|
50
|
+
);
|
|
51
|
+
if (!firstInvalid) return false;
|
|
52
|
+
|
|
53
|
+
// <fieldset> isn't focusable by default; if the matched element is a
|
|
54
|
+
// fieldset (e.g., RadioGroup's group-level aria-invalid), drill in and
|
|
55
|
+
// focus the first non-disabled focusable child. Future grouped-control
|
|
56
|
+
// primitives (fieldsets of date pickers, checkbox groups, etc.) inherit
|
|
57
|
+
// this behaviour without re-derivation.
|
|
58
|
+
if (firstInvalid instanceof HTMLFieldSetElement) {
|
|
59
|
+
const firstFocusable = getFocusableElements(firstInvalid)[0];
|
|
60
|
+
if (firstFocusable) {
|
|
61
|
+
firstFocusable.focus();
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
firstInvalid.focus();
|
|
67
|
+
return true;
|
|
68
|
+
}
|
package/utils/a11y/id.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { generateId, resetIdCounter } from './id.js';
|
|
2
|
+
export {
|
|
3
|
+
createFocusTrap,
|
|
4
|
+
getFocusableElements,
|
|
5
|
+
focusFirstInvalid,
|
|
6
|
+
type FocusTrap,
|
|
7
|
+
} from './focus.js';
|
|
8
|
+
export { onKeyboardNav, type KeyboardNavHandlers } from './keyboard.js';
|
|
9
|
+
export { announce, type LiveRegionPriority } from './live-region.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type KeyboardNavHandlers = {
|
|
2
|
+
ArrowUp?: (event: KeyboardEvent) => void;
|
|
3
|
+
ArrowDown?: (event: KeyboardEvent) => void;
|
|
4
|
+
ArrowLeft?: (event: KeyboardEvent) => void;
|
|
5
|
+
ArrowRight?: (event: KeyboardEvent) => void;
|
|
6
|
+
Enter?: (event: KeyboardEvent) => void;
|
|
7
|
+
Escape?: (event: KeyboardEvent) => void;
|
|
8
|
+
Space?: (event: KeyboardEvent) => void;
|
|
9
|
+
Home?: (event: KeyboardEvent) => void;
|
|
10
|
+
End?: (event: KeyboardEvent) => void;
|
|
11
|
+
PageUp?: (event: KeyboardEvent) => void;
|
|
12
|
+
PageDown?: (event: KeyboardEvent) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function onKeyboardNav(
|
|
16
|
+
element: HTMLElement,
|
|
17
|
+
handlers: KeyboardNavHandlers,
|
|
18
|
+
): () => void {
|
|
19
|
+
function handleKeyDown(event: KeyboardEvent): void {
|
|
20
|
+
const key = event.key === ' ' ? 'Space' : event.key;
|
|
21
|
+
const handler = handlers[key as keyof KeyboardNavHandlers];
|
|
22
|
+
if (handler) {
|
|
23
|
+
handler(event);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
element.addEventListener('keydown', handleKeyDown);
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
element.removeEventListener('keydown', handleKeyDown);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type LiveRegionPriority = 'polite' | 'assertive';
|
|
2
|
+
|
|
3
|
+
const REGION_ID = 'pienter-ui-live-region';
|
|
4
|
+
|
|
5
|
+
function ensureLiveRegion(): HTMLElement {
|
|
6
|
+
let region = document.getElementById(REGION_ID);
|
|
7
|
+
if (!region) {
|
|
8
|
+
region = document.createElement('div');
|
|
9
|
+
region.id = REGION_ID;
|
|
10
|
+
region.setAttribute('aria-live', 'polite');
|
|
11
|
+
region.setAttribute('aria-atomic', 'true');
|
|
12
|
+
region.style.position = 'absolute';
|
|
13
|
+
region.style.width = '1px';
|
|
14
|
+
region.style.height = '1px';
|
|
15
|
+
region.style.padding = '0';
|
|
16
|
+
region.style.margin = '-1px';
|
|
17
|
+
region.style.overflow = 'hidden';
|
|
18
|
+
region.style.clip = 'rect(0, 0, 0, 0)';
|
|
19
|
+
region.style.whiteSpace = 'nowrap';
|
|
20
|
+
region.style.border = '0';
|
|
21
|
+
document.body.appendChild(region);
|
|
22
|
+
}
|
|
23
|
+
return region;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function announce(
|
|
27
|
+
text: string,
|
|
28
|
+
priority: LiveRegionPriority = 'polite',
|
|
29
|
+
): void {
|
|
30
|
+
const region = ensureLiveRegion();
|
|
31
|
+
region.setAttribute('aria-live', priority);
|
|
32
|
+
region.textContent = '';
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
region.textContent = text;
|
|
35
|
+
}, 50);
|
|
36
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { lockScroll } from '../dom/scroll-lock.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Imperative control surface returned by `mountDialog`. The controller
|
|
5
|
+
* owns the dialog's runtime state (open/closed, scroll lock) and lets the
|
|
6
|
+
* native `<dialog>` element handle top-layer rendering, focus trapping,
|
|
7
|
+
* and Escape dismissal. Frameworks (Vue, Astro consumer scripts) act as
|
|
8
|
+
* the trigger source — they call `open()` / `close()` and watch incoming
|
|
9
|
+
* prop changes — but they never own state directly. This avoids the dual
|
|
10
|
+
* state-machine problem where framework state and controller state desync.
|
|
11
|
+
*/
|
|
12
|
+
export interface DialogControl {
|
|
13
|
+
open(): void;
|
|
14
|
+
close(): void;
|
|
15
|
+
isOpen(): boolean;
|
|
16
|
+
teardown(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DialogConfig {
|
|
20
|
+
/**
|
|
21
|
+
* ARIA role assigned to the `<dialog>` element. The browser's default
|
|
22
|
+
* for `<dialog>` is `'dialog'`; only override (e.g. for AlertDialog)
|
|
23
|
+
* when needed. Setting this calls `setAttribute('role', …)`.
|
|
24
|
+
*/
|
|
25
|
+
role?: 'dialog' | 'alertdialog';
|
|
26
|
+
/**
|
|
27
|
+
* When `true`, the controller suppresses the default close behavior on
|
|
28
|
+
* Escape (by calling `event.preventDefault()` on the `cancel` event)
|
|
29
|
+
* and ignores backdrop clicks. Used by AlertDialog. Default: `false`.
|
|
30
|
+
*/
|
|
31
|
+
persistent?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Where focus lands when the dialog opens.
|
|
34
|
+
*
|
|
35
|
+
* The native `<dialog>.showModal()` already auto-focuses the first
|
|
36
|
+
* focusable child (or the element with `[autofocus]`). This config
|
|
37
|
+
* lets callers override that default:
|
|
38
|
+
* - `'first'` (default): defer to the browser's native behavior.
|
|
39
|
+
* - `'cancel'`: focus the element marked `[data-dialog-cancel]`
|
|
40
|
+
* (used by AlertDialog).
|
|
41
|
+
* - `HTMLElement`: explicit element to focus.
|
|
42
|
+
* - `null`: skip override; defer to the browser's native behavior.
|
|
43
|
+
*
|
|
44
|
+
* If a `[data-dialog-initial-focus]` element exists inside the dialog,
|
|
45
|
+
* it ALWAYS wins over the configured value (consumer override).
|
|
46
|
+
*/
|
|
47
|
+
initialFocus?: 'first' | 'cancel' | HTMLElement | null;
|
|
48
|
+
/**
|
|
49
|
+
* Called when the user requests close (Escape key, backdrop click).
|
|
50
|
+
* The controller does NOT auto-close in this case — the framework
|
|
51
|
+
* reflects the close intent back through its prop/v-model and calls
|
|
52
|
+
* `close()` itself, keeping framework state authoritative for the
|
|
53
|
+
* open/closed boolean.
|
|
54
|
+
*/
|
|
55
|
+
onClose?: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Mount the dialog controller on `el`, which MUST be a `<dialog>`
|
|
60
|
+
* element. The native element provides top-layer rendering, focus
|
|
61
|
+
* trapping, Escape dismissal (via the `cancel` event), and the
|
|
62
|
+
* `::backdrop` pseudo-element; this controller layers on body-scroll
|
|
63
|
+
* lock, backdrop-click dismissal, persistent suppression, an
|
|
64
|
+
* `initialFocus` override, and `data-state` reflection so the existing
|
|
65
|
+
* CSS conventions keep working.
|
|
66
|
+
*
|
|
67
|
+
* Lifecycle:
|
|
68
|
+
* 1. On mount: optionally set `role` (AlertDialog uses `'alertdialog'`),
|
|
69
|
+
* wire `cancel` and `click` listeners.
|
|
70
|
+
* 2. `open()`: capture trigger, call `el.showModal()` (native top-layer
|
|
71
|
+
* + focus trap), set `data-state="open"`, lock scroll, apply
|
|
72
|
+
* `initialFocus` override after a frame.
|
|
73
|
+
* 3. `close()`: call `el.close()` (native focus restoration to trigger),
|
|
74
|
+
* set `data-state="closed"`, unlock scroll. A backup focus restore
|
|
75
|
+
* runs only when the browser fails to restore (e.g., the trigger
|
|
76
|
+
* was removed from the DOM mid-open).
|
|
77
|
+
* 4. `teardown()`: ensures close ran, then removes listeners.
|
|
78
|
+
*
|
|
79
|
+
* The controller does NOT decide when to close — `onClose` is invoked
|
|
80
|
+
* on user-requested close (Escape, backdrop click). The caller is
|
|
81
|
+
* responsible for reflecting that into framework state and calling
|
|
82
|
+
* `close()`. The framework's reactive prop is the source of truth for
|
|
83
|
+
* open/closed; the controller is the source of truth for runtime
|
|
84
|
+
* side-effects (focus override, scroll lock).
|
|
85
|
+
*/
|
|
86
|
+
export function mountDialog(
|
|
87
|
+
el: HTMLDialogElement,
|
|
88
|
+
config: DialogConfig = {},
|
|
89
|
+
): DialogControl {
|
|
90
|
+
const onClose = config.onClose;
|
|
91
|
+
const persistent = config.persistent ?? false;
|
|
92
|
+
|
|
93
|
+
// Optional role override — AlertDialog uses `'alertdialog'`. The
|
|
94
|
+
// browser's default for `<dialog>` is already `'dialog'`, so we only
|
|
95
|
+
// setAttribute when the caller asks for something different.
|
|
96
|
+
if (config.role && config.role !== 'dialog') {
|
|
97
|
+
el.setAttribute('role', config.role);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Initial state.
|
|
101
|
+
el.setAttribute('data-state', 'closed');
|
|
102
|
+
|
|
103
|
+
// Per-open transient state.
|
|
104
|
+
let triggerEl: HTMLElement | null = null;
|
|
105
|
+
let unlockScrollFn: (() => void) | null = null;
|
|
106
|
+
|
|
107
|
+
// The native `cancel` event fires on Escape press. Browser default:
|
|
108
|
+
// closes the dialog. For persistent dialogs, suppress the close.
|
|
109
|
+
function handleCancel(event: Event): void {
|
|
110
|
+
if (persistent) {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
onClose?.();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Backdrop click — `<dialog>` IS the backdrop element. Clicks on its
|
|
118
|
+
// children bubble through with `event.target` set to the inner content,
|
|
119
|
+
// so a target equal to `el` means the click landed on the backdrop.
|
|
120
|
+
function handleBackdropClick(event: MouseEvent): void {
|
|
121
|
+
if (persistent) return;
|
|
122
|
+
if (event.target !== el) return;
|
|
123
|
+
onClose?.();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
el.addEventListener('cancel', handleCancel);
|
|
127
|
+
el.addEventListener('click', handleBackdropClick);
|
|
128
|
+
|
|
129
|
+
function resolveInitialFocus(): HTMLElement | null {
|
|
130
|
+
// Consumer override always wins.
|
|
131
|
+
const override = el.querySelector<HTMLElement>(
|
|
132
|
+
'[data-dialog-initial-focus]',
|
|
133
|
+
);
|
|
134
|
+
if (override) return override;
|
|
135
|
+
|
|
136
|
+
const target = config.initialFocus ?? 'first';
|
|
137
|
+
if (target === null) return null;
|
|
138
|
+
if (target instanceof HTMLElement) return target;
|
|
139
|
+
if (target === 'cancel') {
|
|
140
|
+
return el.querySelector<HTMLElement>('[data-dialog-cancel]');
|
|
141
|
+
}
|
|
142
|
+
// 'first' — defer to the native showModal() auto-focus, no override.
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function open(): void {
|
|
147
|
+
if (el.open) return; // idempotent
|
|
148
|
+
|
|
149
|
+
// Capture the element that had focus before the dialog opened so
|
|
150
|
+
// we can restore it on close if the browser fails to do so.
|
|
151
|
+
const active = document.activeElement;
|
|
152
|
+
triggerEl =
|
|
153
|
+
active instanceof HTMLElement && active !== document.body
|
|
154
|
+
? active
|
|
155
|
+
: null;
|
|
156
|
+
|
|
157
|
+
el.showModal(); // native top-layer + focus trap
|
|
158
|
+
el.setAttribute('data-state', 'open');
|
|
159
|
+
unlockScrollFn = lockScroll();
|
|
160
|
+
|
|
161
|
+
// Apply initialFocus override after a frame so the dialog has
|
|
162
|
+
// settled into place. The browser auto-focuses the first focusable
|
|
163
|
+
// child by default — only override when the consumer asked for it.
|
|
164
|
+
requestAnimationFrame(() => {
|
|
165
|
+
if (!el.open) return; // guard against rapid open→close
|
|
166
|
+
const target = resolveInitialFocus();
|
|
167
|
+
if (target) target.focus();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function close(): void {
|
|
172
|
+
if (!el.open) return; // idempotent
|
|
173
|
+
|
|
174
|
+
el.close(); // native focus restoration to the trigger
|
|
175
|
+
el.setAttribute('data-state', 'closed');
|
|
176
|
+
unlockScrollFn?.();
|
|
177
|
+
unlockScrollFn = null;
|
|
178
|
+
|
|
179
|
+
// Backup: if the browser failed to restore focus (e.g. the trigger
|
|
180
|
+
// was removed from the DOM during the open, or the page lost focus),
|
|
181
|
+
// restore manually. We check for activeElement === body which is
|
|
182
|
+
// the browser's "no specific element focused" state.
|
|
183
|
+
const trigger = triggerEl;
|
|
184
|
+
triggerEl = null;
|
|
185
|
+
if (
|
|
186
|
+
trigger &&
|
|
187
|
+
document.contains(trigger) &&
|
|
188
|
+
document.activeElement === document.body
|
|
189
|
+
) {
|
|
190
|
+
trigger.focus();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isOpen(): boolean {
|
|
195
|
+
return el.open;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function teardown(): void {
|
|
199
|
+
if (el.open) close();
|
|
200
|
+
el.removeEventListener('cancel', handleCancel);
|
|
201
|
+
el.removeEventListener('click', handleBackdropClick);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { open, close, isOpen, teardown };
|
|
205
|
+
}
|