@pienter/ui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONVENTIONS.md +1499 -0
  3. package/LICENSE +21 -0
  4. package/README.md +71 -0
  5. package/components/action/button/Button.astro +95 -0
  6. package/components/action/button/Button.vue +92 -0
  7. package/components/action/button/IconButton.astro +86 -0
  8. package/components/action/button/IconButton.vue +85 -0
  9. package/components/action/button/button.css +194 -0
  10. package/components/action/toggle/Toggle.vue +38 -0
  11. package/components/action/toggle/toggle.css +77 -0
  12. package/components/action/toggle-group/ToggleGroup.vue +67 -0
  13. package/components/action/toggle-group/toggle-group.css +78 -0
  14. package/components/display/avatar/Avatar.astro +17 -0
  15. package/components/display/avatar/Avatar.vue +30 -0
  16. package/components/display/avatar/AvatarStack.astro +9 -0
  17. package/components/display/avatar/AvatarStack.vue +11 -0
  18. package/components/display/avatar/avatar.css +58 -0
  19. package/components/display/badge/Badge.astro +15 -0
  20. package/components/display/badge/Badge.vue +23 -0
  21. package/components/display/badge/badge.css +56 -0
  22. package/components/display/empty/Empty.astro +9 -0
  23. package/components/display/empty/Empty.vue +11 -0
  24. package/components/display/empty/empty.css +39 -0
  25. package/components/display/icon/Icon.astro +52 -0
  26. package/components/display/icon/Icon.vue +57 -0
  27. package/components/display/icon/icon.css +47 -0
  28. package/components/feedback/alert/Alert.astro +52 -0
  29. package/components/feedback/alert/Alert.vue +60 -0
  30. package/components/feedback/alert/alert.css +78 -0
  31. package/components/feedback/progress/Progress.astro +68 -0
  32. package/components/feedback/progress/Progress.vue +82 -0
  33. package/components/feedback/progress/progress.css +68 -0
  34. package/components/feedback/skeleton/Skeleton.astro +32 -0
  35. package/components/feedback/skeleton/Skeleton.vue +39 -0
  36. package/components/feedback/skeleton/skeleton.css +56 -0
  37. package/components/feedback/spinner/Spinner.astro +25 -0
  38. package/components/feedback/spinner/Spinner.vue +36 -0
  39. package/components/feedback/spinner/spinner.css +91 -0
  40. package/components/feedback/toast/Toast.astro +50 -0
  41. package/components/feedback/toast/Toast.vue +74 -0
  42. package/components/feedback/toast/toast.css +128 -0
  43. package/components/form/checkbox/Checkbox.astro +79 -0
  44. package/components/form/checkbox/Checkbox.vue +95 -0
  45. package/components/form/checkbox/checkbox.css +59 -0
  46. package/components/form/combobox/Combobox.vue +508 -0
  47. package/components/form/combobox/combobox.css +110 -0
  48. package/components/form/date-input/DateInput.astro +105 -0
  49. package/components/form/date-input/DateInput.vue +121 -0
  50. package/components/form/date-input/date-input.css +19 -0
  51. package/components/form/form/Form.astro +106 -0
  52. package/components/form/form/Form.vue +181 -0
  53. package/components/form/form/form.css +46 -0
  54. package/components/form/input-otp/InputOTP.astro +147 -0
  55. package/components/form/input-otp/InputOTP.vue +209 -0
  56. package/components/form/input-otp/input-otp.css +52 -0
  57. package/components/form/label/Label.astro +13 -0
  58. package/components/form/label/Label.vue +20 -0
  59. package/components/form/label/label.css +11 -0
  60. package/components/form/number-field/NumberField.astro +142 -0
  61. package/components/form/number-field/NumberField.vue +155 -0
  62. package/components/form/number-field/number-field.css +115 -0
  63. package/components/form/radio-group/RadioGroup.astro +105 -0
  64. package/components/form/radio-group/RadioGroup.vue +110 -0
  65. package/components/form/radio-group/radio-group.css +114 -0
  66. package/components/form/radio-group/types.ts +14 -0
  67. package/components/form/select/Segmented.vue +36 -0
  68. package/components/form/select/Select.astro +105 -0
  69. package/components/form/select/Select.vue +109 -0
  70. package/components/form/select/select.css +96 -0
  71. package/components/form/slider/Slider.astro +205 -0
  72. package/components/form/slider/Slider.vue +321 -0
  73. package/components/form/slider/slider.css +115 -0
  74. package/components/form/switch/Switch.astro +75 -0
  75. package/components/form/switch/Switch.vue +89 -0
  76. package/components/form/switch/switch.css +64 -0
  77. package/components/form/tags-input/TagsInput.astro +153 -0
  78. package/components/form/tags-input/TagsInput.vue +207 -0
  79. package/components/form/tags-input/tags-input.css +128 -0
  80. package/components/form/text-input/TextInput.astro +84 -0
  81. package/components/form/text-input/TextInput.vue +99 -0
  82. package/components/form/text-input/text-input.css +165 -0
  83. package/components/form/textarea/Textarea.astro +86 -0
  84. package/components/form/textarea/Textarea.vue +102 -0
  85. package/components/form/textarea/textarea.css +25 -0
  86. package/components/layout/accordion/Accordion.vue +59 -0
  87. package/components/layout/accordion/accordion.css +87 -0
  88. package/components/layout/card/Card.astro +13 -0
  89. package/components/layout/card/Card.vue +20 -0
  90. package/components/layout/card/card.css +55 -0
  91. package/components/layout/collapsible/Collapsible.vue +77 -0
  92. package/components/layout/collapsible/collapsible.css +76 -0
  93. package/components/layout/separator/Separator.astro +31 -0
  94. package/components/layout/separator/Separator.vue +33 -0
  95. package/components/layout/separator/separator.css +27 -0
  96. package/components/layout/table/DataTable.vue +127 -0
  97. package/components/layout/table/Table.astro +116 -0
  98. package/components/layout/table/Table.vue +146 -0
  99. package/components/layout/table/TableRow.vue +59 -0
  100. package/components/layout/table/table.css +201 -0
  101. package/components/layout/table/types.ts +35 -0
  102. package/components/layout/table/useTable.ts +7 -0
  103. package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
  104. package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
  105. package/components/navigation/breadcrumb/breadcrumb.css +37 -0
  106. package/components/navigation/navbar/Navbar.astro +62 -0
  107. package/components/navigation/navbar/Navbar.vue +50 -0
  108. package/components/navigation/navbar/navbar.css +77 -0
  109. package/components/navigation/pagination/Pagination.vue +107 -0
  110. package/components/navigation/pagination/pagination.css +53 -0
  111. package/components/navigation/sidebar/Sidebar.astro +132 -0
  112. package/components/navigation/sidebar/Sidebar.vue +174 -0
  113. package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
  114. package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
  115. package/components/navigation/sidebar/sidebar.css +303 -0
  116. package/components/navigation/sidebar/types.ts +72 -0
  117. package/components/navigation/tabs/Tabs.vue +84 -0
  118. package/components/navigation/tabs/tabs.css +39 -0
  119. package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
  120. package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
  121. package/components/overlay/alert-dialog/alert-dialog.css +57 -0
  122. package/components/overlay/command/Command.vue +356 -0
  123. package/components/overlay/command/command.css +179 -0
  124. package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
  125. package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
  126. package/components/overlay/modal/Modal.astro +66 -0
  127. package/components/overlay/modal/Modal.vue +85 -0
  128. package/components/overlay/modal/modal.css +60 -0
  129. package/components/overlay/popover/Popover.vue +113 -0
  130. package/components/overlay/popover/popover.css +53 -0
  131. package/components/overlay/sheet/Sheet.vue +88 -0
  132. package/components/overlay/sheet/sheet.css +108 -0
  133. package/components/overlay/tooltip/Tooltip.vue +210 -0
  134. package/components/overlay/tooltip/tooltip.css +50 -0
  135. package/composables/useUrlSort.ts +48 -0
  136. package/icons/alert-triangle.ts +1 -0
  137. package/icons/arrow-down.ts +1 -0
  138. package/icons/arrow-up-down.ts +5 -0
  139. package/icons/arrow-up.ts +1 -0
  140. package/icons/bell.ts +1 -0
  141. package/icons/check.ts +1 -0
  142. package/icons/chevron-down.ts +1 -0
  143. package/icons/chevron-left.ts +1 -0
  144. package/icons/chevron-right.ts +1 -0
  145. package/icons/chevron-up-down.ts +5 -0
  146. package/icons/chevron-up.ts +1 -0
  147. package/icons/circle-alert.ts +1 -0
  148. package/icons/circle-check.ts +1 -0
  149. package/icons/clipboard.ts +1 -0
  150. package/icons/download.ts +1 -0
  151. package/icons/edit.ts +1 -0
  152. package/icons/external-link.ts +1 -0
  153. package/icons/eye.ts +1 -0
  154. package/icons/file.ts +1 -0
  155. package/icons/filter.ts +1 -0
  156. package/icons/folder.ts +1 -0
  157. package/icons/image.ts +1 -0
  158. package/icons/inbox.ts +1 -0
  159. package/icons/index.ts +91 -0
  160. package/icons/info.ts +1 -0
  161. package/icons/layers.ts +1 -0
  162. package/icons/link-2.ts +1 -0
  163. package/icons/list.ts +1 -0
  164. package/icons/loader.ts +3 -0
  165. package/icons/menu.ts +1 -0
  166. package/icons/more-horizontal.ts +1 -0
  167. package/icons/more-vertical.ts +1 -0
  168. package/icons/plus-circle.ts +1 -0
  169. package/icons/plus.ts +1 -0
  170. package/icons/save.ts +1 -0
  171. package/icons/search.ts +1 -0
  172. package/icons/send.ts +1 -0
  173. package/icons/settings.ts +1 -0
  174. package/icons/tool.ts +1 -0
  175. package/icons/trash-2.ts +1 -0
  176. package/icons/trash.ts +1 -0
  177. package/icons/upload-cloud.ts +1 -0
  178. package/icons/upload.ts +1 -0
  179. package/icons/x.ts +1 -0
  180. package/package.json +150 -0
  181. package/styles/0-settings/colors.css +241 -0
  182. package/styles/0-settings/index.css +5 -0
  183. package/styles/0-settings/layout.css +52 -0
  184. package/styles/0-settings/motion.css +11 -0
  185. package/styles/0-settings/spacing.css +15 -0
  186. package/styles/0-settings/typography.css +37 -0
  187. package/styles/0-utils/index.css +1 -0
  188. package/styles/1-reset/index.css +1 -0
  189. package/styles/1-reset/reset.css +26 -0
  190. package/styles/2-base/base.css +42 -0
  191. package/styles/2-base/forms.css +23 -0
  192. package/styles/2-base/index.css +2 -0
  193. package/styles/3-layout/container.css +57 -0
  194. package/styles/3-layout/index.css +2 -0
  195. package/styles/3-layout/section.css +17 -0
  196. package/styles/5-utilities/accessibility.css +13 -0
  197. package/styles/5-utilities/index.css +2 -0
  198. package/styles/5-utilities/text.css +5 -0
  199. package/styles/main.css +8 -0
  200. package/styles/styles.d.ts +6 -0
  201. package/utils/a11y/focus.ts +68 -0
  202. package/utils/a11y/id.ts +10 -0
  203. package/utils/a11y/index.ts +9 -0
  204. package/utils/a11y/keyboard.ts +32 -0
  205. package/utils/a11y/live-region.ts +36 -0
  206. package/utils/controllers/dialog.ts +205 -0
  207. package/utils/controllers/disclosure.ts +117 -0
  208. package/utils/controllers/form.ts +524 -0
  209. package/utils/controllers/index.ts +39 -0
  210. package/utils/controllers/menu.ts +255 -0
  211. package/utils/controllers/number-field.ts +103 -0
  212. package/utils/controllers/otp.ts +252 -0
  213. package/utils/controllers/popover.ts +434 -0
  214. package/utils/controllers/sidebar.ts +610 -0
  215. package/utils/controllers/slider.ts +336 -0
  216. package/utils/controllers/tags-input.ts +255 -0
  217. package/utils/controllers/toast.ts +426 -0
  218. package/utils/dom/index.ts +1 -0
  219. package/utils/dom/scroll-lock.ts +48 -0
  220. package/utils/index.ts +3 -0
  221. package/utils/sort/index.ts +3 -0
  222. package/utils/sort/serialize.ts +19 -0
  223. package/utils/sort/state.ts +11 -0
  224. package/utils/sort/types.ts +15 -0
  225. package/utils/validation/form.ts +93 -0
  226. package/utils/validation/index.ts +13 -0
  227. package/utils/validation/rules.ts +31 -0
@@ -0,0 +1,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,2 @@
1
+ @import url('./base.css');
2
+ @import url('./forms.css');
@@ -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,2 @@
1
+ @import url('./section.css');
2
+ @import url('./container.css');
@@ -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
+ }
@@ -0,0 +1,13 @@
1
+ @layer utilities {
2
+ .u-visually-hidden {
3
+ position: absolute;
4
+ width: 1px;
5
+ height: 1px;
6
+ padding: 0;
7
+ margin: -1px;
8
+ overflow: hidden;
9
+ clip: rect(0, 0, 0, 0);
10
+ white-space: nowrap;
11
+ border: 0;
12
+ }
13
+ }
@@ -0,0 +1,2 @@
1
+ @import url('./accessibility.css');
2
+ @import url('./text.css');
@@ -0,0 +1,5 @@
1
+ @layer utilities {
2
+ .u-muted {
3
+ color: var(--text-clr-muted);
4
+ }
5
+ }
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ let counter = 0;
2
+
3
+ export function generateId(prefix: string): string {
4
+ counter += 1;
5
+ return `${prefix}-${counter}`;
6
+ }
7
+
8
+ export function resetIdCounter(): void {
9
+ counter = 0;
10
+ }
@@ -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
+ }