@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,105 @@
1
+ ---
2
+ import './select.css';
3
+ import Icon from '../../display/icon/Icon.astro';
4
+ import { generateId } from '../../../utils/a11y/id.js';
5
+
6
+ interface Props {
7
+ id?: string;
8
+ label?: string;
9
+ name?: string;
10
+ value?: string;
11
+ disabled?: boolean;
12
+ options: { value: string; label: string }[];
13
+ placeholder?: string;
14
+ hint?: string;
15
+ errors?: string[];
16
+ status?: 'error' | 'success';
17
+ }
18
+
19
+ const {
20
+ id,
21
+ label,
22
+ name,
23
+ value,
24
+ disabled = false,
25
+ options,
26
+ placeholder,
27
+ hint,
28
+ errors = [],
29
+ status,
30
+ ...rest
31
+ } = Astro.props;
32
+
33
+ const inputId = id ?? generateId('select');
34
+ const hintId = `${inputId}-hint`;
35
+ const errorsId = `${inputId}-errors`;
36
+
37
+ const hasErrors = errors.length > 0;
38
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
39
+
40
+ const describedByIds: string[] = [];
41
+ if (hint) describedByIds.push(hintId);
42
+ if (hasErrors) describedByIds.push(errorsId);
43
+ const describedBy =
44
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
45
+ ---
46
+
47
+ <div class="pui-field" data-status={computedStatus}>
48
+ {
49
+ label && (
50
+ <label class="pui-field__label" for={inputId}>
51
+ {label}
52
+ </label>
53
+ )
54
+ }
55
+ <div class="pui-select">
56
+ <select
57
+ class="pui-select__control"
58
+ id={inputId}
59
+ name={name}
60
+ disabled={disabled}
61
+ aria-invalid={hasErrors ? 'true' : undefined}
62
+ aria-describedby={describedBy}
63
+ {...rest}
64
+ >
65
+ {
66
+ placeholder && (
67
+ <option
68
+ value=""
69
+ disabled
70
+ hidden
71
+ selected={value === undefined || value === ''}
72
+ >
73
+ {placeholder}
74
+ </option>
75
+ )
76
+ }
77
+ {
78
+ options.map((opt) => (
79
+ <option value={opt.value} selected={value === opt.value}>
80
+ {opt.label}
81
+ </option>
82
+ ))
83
+ }
84
+ </select>
85
+ <span class="pui-select__caret" aria-hidden="true">
86
+ <Icon name="chevron-down" size="sm" />
87
+ </span>
88
+ </div>
89
+ {
90
+ hint && (
91
+ <p id={hintId} class="pui-field__hint">
92
+ {hint}
93
+ </p>
94
+ )
95
+ }
96
+ {
97
+ hasErrors && (
98
+ <ul id={errorsId} class="pui-field__hint" role="alert">
99
+ {errors.map((error) => (
100
+ <li>{error}</li>
101
+ ))}
102
+ </ul>
103
+ )
104
+ }
105
+ </div>
@@ -0,0 +1,109 @@
1
+ <template>
2
+ <div class="pui-field" :data-status="computedStatus">
3
+ <label v-if="label" class="pui-field__label" :for="inputId">{{
4
+ label
5
+ }}</label>
6
+ <div class="pui-select">
7
+ <select
8
+ v-bind="$attrs"
9
+ :id="inputId"
10
+ class="pui-select__control"
11
+ :name="name"
12
+ :disabled="disabled"
13
+ :value="modelValue"
14
+ :aria-invalid="hasErrors ? 'true' : undefined"
15
+ :aria-describedby="describedBy"
16
+ @change="
17
+ emit(
18
+ 'update:modelValue',
19
+ ($event.target as HTMLSelectElement).value,
20
+ )
21
+ "
22
+ >
23
+ <option v-if="placeholder" value="" disabled hidden>
24
+ {{ placeholder }}
25
+ </option>
26
+ <option
27
+ v-for="opt in options"
28
+ :key="opt.value"
29
+ :value="opt.value"
30
+ >
31
+ {{ opt.label }}
32
+ </option>
33
+ </select>
34
+ <span class="pui-select__caret" aria-hidden="true">
35
+ <Icon name="chevron-down" size="sm" />
36
+ </span>
37
+ </div>
38
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
39
+ <ul
40
+ v-if="errors.length"
41
+ :id="errorsId"
42
+ class="pui-field__hint"
43
+ role="alert"
44
+ >
45
+ <li v-for="error in errors" :key="error">{{ error }}</li>
46
+ </ul>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import { computed } from 'vue';
52
+ import { generateId } from '../../../utils/a11y/id.js';
53
+ import Icon from '../../display/icon/Icon.vue';
54
+
55
+ defineOptions({ inheritAttrs: false });
56
+
57
+ const props = withDefaults(
58
+ defineProps<{
59
+ id?: string;
60
+ label?: string;
61
+ modelValue?: string;
62
+ name?: string;
63
+ disabled?: boolean;
64
+ options: { value: string; label: string }[];
65
+ placeholder?: string;
66
+ hint?: string;
67
+ errors?: string[];
68
+ status?: 'error' | 'success';
69
+ }>(),
70
+ {
71
+ id: undefined,
72
+ label: undefined,
73
+ modelValue: undefined,
74
+ name: undefined,
75
+ disabled: false,
76
+ placeholder: undefined,
77
+ hint: undefined,
78
+ errors: () => [],
79
+ status: undefined,
80
+ },
81
+ );
82
+
83
+ const emit = defineEmits<{
84
+ 'update:modelValue': [value: string];
85
+ }>();
86
+
87
+ const inputId = props.id ?? generateId('select');
88
+ const hintId = `${inputId}-hint`;
89
+ const errorsId = `${inputId}-errors`;
90
+
91
+ const hasErrors = computed(() => props.errors.length > 0);
92
+
93
+ const computedStatus = computed(() => {
94
+ if (props.status) return props.status;
95
+ if (hasErrors.value) return 'error';
96
+ return undefined;
97
+ });
98
+
99
+ const describedBy = computed(() => {
100
+ const ids: string[] = [];
101
+ if (props.hint) ids.push(hintId);
102
+ if (hasErrors.value) ids.push(errorsId);
103
+ return ids.length > 0 ? ids.join(' ') : undefined;
104
+ });
105
+ </script>
106
+
107
+ <style>
108
+ @import './select.css';
109
+ </style>
@@ -0,0 +1,96 @@
1
+ @layer components {
2
+ /* ===== pui-select (form-primitive control) =====
3
+ *
4
+ * Wrapped by `.pui-field` (the shared form-primitive scaffold owned by
5
+ * text-input.css). The status-driven recolour (`error` / `success` border)
6
+ * is applied by the parent `.pui-field[data-status]` selector below, mirroring
7
+ * `.pui-input` in text-input.css. Focus ring uses the same box-shadow pattern
8
+ * as `.pui-input` for visual consistency.
9
+ */
10
+
11
+ .pui-select {
12
+ position: relative;
13
+ display: inline-block;
14
+ width: 100%;
15
+ }
16
+
17
+ .pui-select__control {
18
+ appearance: none;
19
+ width: 100%;
20
+ padding: 0.65em 2.4em 0.65em 0.85em;
21
+ font: inherit;
22
+ background: var(--bg-clr-surface);
23
+ border: var(--stroke-sm) solid var(--border-clr-base);
24
+ border-radius: var(--radius-sm);
25
+ cursor: pointer;
26
+ color: var(--text-clr-base);
27
+ transition:
28
+ border-color 0.12s ease,
29
+ box-shadow 0.12s ease;
30
+ }
31
+
32
+ .pui-select__control:focus-visible {
33
+ outline: none;
34
+ border-color: var(--border-clr-brand);
35
+ box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
36
+ }
37
+
38
+ .pui-select__control:disabled {
39
+ background: var(--bg-clr-surface-2);
40
+ color: var(--text-clr-muted);
41
+ cursor: not-allowed;
42
+ }
43
+
44
+ .pui-select__caret {
45
+ position: absolute;
46
+ right: 0.85em;
47
+ top: 50%;
48
+ transform: translateY(-50%);
49
+ pointer-events: none;
50
+ color: var(--text-clr-muted);
51
+ }
52
+
53
+ /* Status states on parent .pui-field — mirrors `.pui-input` / `.pui-textarea`. */
54
+
55
+ .pui-field[data-status='error'] .pui-select__control {
56
+ border-color: var(--border-clr-danger);
57
+ }
58
+
59
+ .pui-field[data-status='success'] .pui-select__control {
60
+ border-color: var(--border-clr-success);
61
+ }
62
+
63
+ /* ===== pui-segmented (Segmented control — Phase 4 retrofit) ===== */
64
+
65
+ .pui-segmented {
66
+ display: inline-flex;
67
+ background: var(--bg-clr-surface-2);
68
+ border: var(--stroke-sm) solid var(--border-clr-base);
69
+ border-radius: var(--radius-sm);
70
+ padding: 3px;
71
+ gap: 2px;
72
+ }
73
+ .pui-segmented__item {
74
+ appearance: none;
75
+ border: 0;
76
+ background: transparent;
77
+ padding: 0.4em 0.85em;
78
+ font: inherit;
79
+ font-size: var(--step--1);
80
+ font-weight: var(--fw-semibold);
81
+ color: var(--text-clr-muted);
82
+ border-radius: calc(var(--radius-sm) - 1px);
83
+ cursor: pointer;
84
+ transition:
85
+ background 0.12s ease,
86
+ color 0.12s ease;
87
+ }
88
+ .pui-segmented__item:hover {
89
+ color: var(--text-clr-base);
90
+ }
91
+ .pui-segmented__item[aria-pressed='true'] {
92
+ background: var(--bg-clr-surface);
93
+ color: var(--text-clr-base);
94
+ box-shadow: 0 1px 2px rgba(15, 21, 48, 0.08);
95
+ }
96
+ }
@@ -0,0 +1,205 @@
1
+ ---
2
+ import './slider.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ id?: string;
7
+ label: string;
8
+ name?: string;
9
+ /**
10
+ * Initial value. Pass a `number` for single-thumb mode, a tuple for
11
+ * range. Astro renders the thumb DOM accordingly; the controller
12
+ * picks the matching mode at mount time.
13
+ */
14
+ value?: number | [number, number];
15
+ min?: number;
16
+ max?: number;
17
+ step?: number;
18
+ disabled?: boolean;
19
+ hint?: string;
20
+ errors?: string[];
21
+ status?: 'error' | 'success';
22
+ }
23
+
24
+ const {
25
+ id,
26
+ label,
27
+ name,
28
+ value = 0,
29
+ min = 0,
30
+ max = 100,
31
+ step = 1,
32
+ disabled = false,
33
+ hint,
34
+ errors = [],
35
+ status,
36
+ ...rest
37
+ } = Astro.props;
38
+
39
+ const baseId = id ?? generateId('input');
40
+ const hintId = `${baseId}-hint`;
41
+ const errorsId = `${baseId}-errors`;
42
+
43
+ const hasErrors = errors.length > 0;
44
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
45
+
46
+ const describedByIds: string[] = [];
47
+ if (hint) describedByIds.push(hintId);
48
+ if (hasErrors) describedByIds.push(errorsId);
49
+ const describedBy =
50
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
51
+
52
+ const isRange = Array.isArray(value);
53
+ const values: number[] = isRange ? [value[0], value[1]] : [value as number];
54
+
55
+ function pct(v: number): number {
56
+ if (max === min) return 0;
57
+ return ((v - min) / (max - min)) * 100;
58
+ }
59
+
60
+ const fillStart = isRange && values.length === 2 ? `${pct(values[0])}%` : '0%';
61
+ const fillEnd =
62
+ isRange && values.length === 2 ? `${pct(values[1])}%` : `${pct(values[0])}%`;
63
+
64
+ const trackStyle = `--pui-slider-fill-start: ${fillStart}; --pui-slider-fill-end: ${fillEnd};`;
65
+ ---
66
+
67
+ <div class="pui-field" data-status={computedStatus}>
68
+ <label class="pui-field__label" for={`${baseId}-thumb-0`}>
69
+ {label}
70
+ </label>
71
+ <div class="pui-slider-shell">
72
+ <div
73
+ class="pui-slider"
74
+ data-pui-slider
75
+ data-min={String(min)}
76
+ data-max={String(max)}
77
+ data-step={String(step)}
78
+ data-disabled={disabled ? 'true' : undefined}
79
+ data-range={isRange ? 'true' : undefined}
80
+ data-initial={JSON.stringify(values)}
81
+ style={trackStyle}
82
+ {...rest}
83
+ >
84
+ <div class="pui-slider__rail" aria-hidden="true"></div>
85
+ <div class="pui-slider__fill" aria-hidden="true"></div>
86
+ {
87
+ values.map((v, idx) => (
88
+ <button
89
+ id={`${baseId}-thumb-${idx}`}
90
+ type="button"
91
+ class="pui-slider__thumb"
92
+ role="slider"
93
+ aria-valuemin={String(min)}
94
+ aria-valuemax={String(max)}
95
+ aria-valuenow={String(v)}
96
+ aria-valuetext={
97
+ isRange
98
+ ? idx === 0
99
+ ? `Lower bound: ${v}`
100
+ : `Upper bound: ${v}`
101
+ : String(v)
102
+ }
103
+ aria-label={
104
+ isRange
105
+ ? idx === 0
106
+ ? `${label} (lower bound)`
107
+ : `${label} (upper bound)`
108
+ : label
109
+ }
110
+ aria-disabled={disabled ? 'true' : undefined}
111
+ aria-describedby={idx === 0 ? describedBy : undefined}
112
+ tabindex={disabled ? -1 : 0}
113
+ style={`inset-inline-start: ${pct(v)}%`}
114
+ />
115
+ ))
116
+ }
117
+ </div>
118
+ {
119
+ name && !isRange && (
120
+ <input
121
+ class="pui-slider__hidden"
122
+ type="hidden"
123
+ name={name}
124
+ value={String(values[0])}
125
+ />
126
+ )
127
+ }
128
+ {
129
+ name && isRange && (
130
+ <Fragment>
131
+ <input
132
+ class="pui-slider__hidden"
133
+ data-bound="lower"
134
+ type="hidden"
135
+ name={`${name}_lower`}
136
+ value={String(values[0])}
137
+ />
138
+ <input
139
+ class="pui-slider__hidden"
140
+ data-bound="upper"
141
+ type="hidden"
142
+ name={`${name}_upper`}
143
+ value={String(values[1])}
144
+ />
145
+ </Fragment>
146
+ )
147
+ }
148
+ </div>
149
+ {
150
+ hint && (
151
+ <p id={hintId} class="pui-field__hint">
152
+ {hint}
153
+ </p>
154
+ )
155
+ }
156
+ {
157
+ hasErrors && (
158
+ <ul id={errorsId} class="pui-field__hint" role="alert">
159
+ {errors.map((error) => (
160
+ <li>{error}</li>
161
+ ))}
162
+ </ul>
163
+ )
164
+ }
165
+ </div>
166
+
167
+ <script>
168
+ import { mountSlider } from '@pienter/ui/utils/controllers/slider.js';
169
+
170
+ function mountAll(): void {
171
+ const tracks = document.querySelectorAll<HTMLElement>('[data-pui-slider]');
172
+ tracks.forEach((track) => {
173
+ if (track.dataset.vSliderMounted === 'true') return;
174
+ let initial: number | [number, number] | undefined;
175
+ if (track.dataset.initial) {
176
+ try {
177
+ const parsed: unknown = JSON.parse(track.dataset.initial);
178
+ if (
179
+ Array.isArray(parsed) &&
180
+ parsed.length === 2 &&
181
+ parsed.every((v) => typeof v === 'number')
182
+ ) {
183
+ initial = [parsed[0] as number, parsed[1] as number];
184
+ } else if (
185
+ Array.isArray(parsed) &&
186
+ parsed.length === 1 &&
187
+ typeof parsed[0] === 'number'
188
+ ) {
189
+ initial = parsed[0];
190
+ }
191
+ } catch {
192
+ initial = undefined;
193
+ }
194
+ }
195
+ mountSlider(track, { initialValue: initial });
196
+ track.dataset.vSliderMounted = 'true';
197
+ });
198
+ }
199
+
200
+ if (document.readyState === 'loading') {
201
+ document.addEventListener('DOMContentLoaded', mountAll, { once: true });
202
+ } else {
203
+ mountAll();
204
+ }
205
+ </script>