@getdom/studio 0.1.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 (213) hide show
  1. package/LICENSE.md +109 -0
  2. package/README.md +44 -0
  3. package/SKILL.md +83 -0
  4. package/package.json +73 -0
  5. package/public/llms.txt +57 -0
  6. package/public/skills.sh +11 -0
  7. package/src/pages/_layout/componentCatalog.generated.js +4514 -0
  8. package/src/pages/_layout/componentInspector.js +60 -0
  9. package/src/pages/_layout/componentManager.js +192 -0
  10. package/src/pages/_layout/componentRuntimeRecords.js +49 -0
  11. package/src/pages/_layout/inspector/componentRegistry.js +639 -0
  12. package/src/pages/_layout/inspector/serialize.js +134 -0
  13. package/src/pages/_layout/inspector/useInspector.js +293 -0
  14. package/src/pages/components/accordion/DomAccordion.vue +39 -0
  15. package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
  16. package/src/pages/components/button/DomButton.vue +132 -0
  17. package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
  18. package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
  19. package/src/pages/components/dialog/DomDialog.vue +271 -0
  20. package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
  21. package/src/pages/components/dialog/DomDialogStack.vue +289 -0
  22. package/src/pages/components/dialog/useDialogs.js +253 -0
  23. package/src/pages/components/drawer/DomDrawer.vue +159 -0
  24. package/src/pages/components/dropdown/DomDropdown.vue +261 -0
  25. package/src/pages/components/icon-button/DomIconButton.vue +155 -0
  26. package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
  27. package/src/pages/components/menu/DomMenu.vue +359 -0
  28. package/src/pages/components/menu/DomMenuItem.vue +233 -0
  29. package/src/pages/components/menu/DomMenuLabel.vue +19 -0
  30. package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
  31. package/src/pages/components/menu/menuContext.js +11 -0
  32. package/src/pages/components/menu/menuPlacement.js +26 -0
  33. package/src/pages/components/menu/submenu.js +58 -0
  34. package/src/pages/components/pagination/DomPagination.vue +265 -0
  35. package/src/pages/components/playground/DomRenderer.vue +48 -0
  36. package/src/pages/components/popover/DomPopover.vue +244 -0
  37. package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
  38. package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
  39. package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
  40. package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
  41. package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
  42. package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
  43. package/src/pages/components/stepper/DomStepper.vue +386 -0
  44. package/src/pages/components/tabs/DomTabs.vue +149 -0
  45. package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
  46. package/src/pages/components/toast/DomToastItem.vue +52 -0
  47. package/src/pages/components/toast/DomToastStack.vue +768 -0
  48. package/src/pages/components/toast/useToasts.js +237 -0
  49. package/src/pages/components/toolbar/DomToolbar.vue +158 -0
  50. package/src/pages/components/tooltip/DomTooltip.vue +69 -0
  51. package/src/pages/components/tooltip/vDomTooltip.js +106 -0
  52. package/src/pages/components/tree-view/DomTreeView.vue +961 -0
  53. package/src/pages/components/tree-view/treeSchemas.js +129 -0
  54. package/src/pages/components/tree-view/treeUtils.js +228 -0
  55. package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
  56. package/src/pages/data/_shared/resourceRecord.js +29 -0
  57. package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
  58. package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
  59. package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
  60. package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
  61. package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
  62. package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
  63. package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
  64. package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
  65. package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
  66. package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
  67. package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
  68. package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
  69. package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
  70. package/src/pages/data/kanban/DomKanban.vue +1703 -0
  71. package/src/pages/data/kanban/kanbanApi.js +80 -0
  72. package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
  73. package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
  74. package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
  75. package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
  76. package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
  77. package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
  78. package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
  79. package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
  80. package/src/pages/forms/_shared/options.js +61 -0
  81. package/src/pages/forms/_shared/useOptionMenu.js +59 -0
  82. package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
  83. package/src/pages/forms/calendar/DomCalendar.vue +442 -0
  84. package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
  85. package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
  86. package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
  87. package/src/pages/forms/color-input/DomColorInput.vue +118 -0
  88. package/src/pages/forms/combobox/DomCombobox.vue +237 -0
  89. package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
  90. package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
  91. package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
  92. package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
  93. package/src/pages/forms/field/DomField.vue +267 -0
  94. package/src/pages/forms/field/fieldLayout.js +1 -0
  95. package/src/pages/forms/field/fieldProps.js +114 -0
  96. package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
  97. package/src/pages/forms/field/useField.js +343 -0
  98. package/src/pages/forms/field/validators.js +364 -0
  99. package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
  100. package/src/pages/forms/form/DomForm.vue +985 -0
  101. package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
  102. package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
  103. package/src/pages/forms/form/adapters/shared.js +53 -0
  104. package/src/pages/forms/form/adapters/zod.js +235 -0
  105. package/src/pages/forms/form/formApi.js +38 -0
  106. package/src/pages/forms/form/formDefinition.js +392 -0
  107. package/src/pages/forms/form/schemaAdapter.js +54 -0
  108. package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
  109. package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
  110. package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
  111. package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
  112. package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
  113. package/src/pages/forms/listbox/DomListbox.vue +105 -0
  114. package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
  115. package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
  116. package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
  117. package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
  118. package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
  119. package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
  120. package/src/pages/forms/password-strength/passwordStrength.js +92 -0
  121. package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
  122. package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
  123. package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
  124. package/src/pages/forms/select/DomSelect.vue +479 -0
  125. package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
  126. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
  127. package/src/pages/forms/text-input/DomTextInput.vue +48 -0
  128. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
  129. package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
  130. package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
  131. package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
  132. package/src/pages/forms/time-selector/timeUtils.js +194 -0
  133. package/src/pages/forms/toggle/DomToggle.vue +158 -0
  134. package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
  135. package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
  136. package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
  137. package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
  138. package/src/pages/lib/css/color.js +245 -0
  139. package/src/pages/lib/headless/accordion.d.ts +7 -0
  140. package/src/pages/lib/headless/accordion.js +246 -0
  141. package/src/pages/lib/headless/autocomplete.d.ts +4 -0
  142. package/src/pages/lib/headless/autocomplete.js +33 -0
  143. package/src/pages/lib/headless/base.d.ts +25 -0
  144. package/src/pages/lib/headless/base.js +127 -0
  145. package/src/pages/lib/headless/checkbox.d.ts +19 -0
  146. package/src/pages/lib/headless/checkbox.js +99 -0
  147. package/src/pages/lib/headless/combobox.d.ts +7 -0
  148. package/src/pages/lib/headless/combobox.js +488 -0
  149. package/src/pages/lib/headless/dialog.d.ts +23 -0
  150. package/src/pages/lib/headless/dialog.js +384 -0
  151. package/src/pages/lib/headless/drawer.d.ts +21 -0
  152. package/src/pages/lib/headless/drawer.js +682 -0
  153. package/src/pages/lib/headless/dropdown.d.ts +19 -0
  154. package/src/pages/lib/headless/dropdown.js +412 -0
  155. package/src/pages/lib/headless/floating-effect.js +118 -0
  156. package/src/pages/lib/headless/floating.d.ts +34 -0
  157. package/src/pages/lib/headless/floating.js +382 -0
  158. package/src/pages/lib/headless/index.d.ts +19 -0
  159. package/src/pages/lib/headless/index.js +19 -0
  160. package/src/pages/lib/headless/listbox.d.ts +19 -0
  161. package/src/pages/lib/headless/listbox.js +126 -0
  162. package/src/pages/lib/headless/menu.d.ts +18 -0
  163. package/src/pages/lib/headless/menu.js +117 -0
  164. package/src/pages/lib/headless/popover-panel.d.ts +18 -0
  165. package/src/pages/lib/headless/popover-panel.js +309 -0
  166. package/src/pages/lib/headless/popover.d.ts +19 -0
  167. package/src/pages/lib/headless/popover.js +391 -0
  168. package/src/pages/lib/headless/radio-group.d.ts +19 -0
  169. package/src/pages/lib/headless/radio-group.js +100 -0
  170. package/src/pages/lib/headless/tabs.d.ts +18 -0
  171. package/src/pages/lib/headless/tabs.js +119 -0
  172. package/src/pages/lib/headless/toast.d.ts +13 -0
  173. package/src/pages/lib/headless/toast.js +65 -0
  174. package/src/pages/lib/headless/toggle.d.ts +19 -0
  175. package/src/pages/lib/headless/toggle.js +162 -0
  176. package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
  177. package/src/pages/lib/headless/tooltip-controller.js +549 -0
  178. package/src/pages/lib/headless/tooltip.d.ts +19 -0
  179. package/src/pages/lib/headless/tooltip.js +153 -0
  180. package/src/pages/lib/headless/transitions.d.ts +5 -0
  181. package/src/pages/lib/headless/transitions.js +89 -0
  182. package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
  183. package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
  184. package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
  185. package/src/pages/lib/vue/codeHighlighter.js +132 -0
  186. package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
  187. package/src/pages/lib/vue/index.d.ts +6806 -0
  188. package/src/pages/lib/vue/index.js +218 -0
  189. package/src/pages/lib/vue/useTheme.js +116 -0
  190. package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
  191. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
  192. package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
  193. package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
  194. package/src/pages/mobile/shell/DomAppShell.vue +51 -0
  195. package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
  196. package/src/pages/mobile/stack/DomAppStack.vue +711 -0
  197. package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
  198. package/src/pages/visual/alert/DomAlert.vue +363 -0
  199. package/src/pages/visual/avatar/DomAvatar.vue +110 -0
  200. package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
  201. package/src/pages/visual/badge/DomBadge.vue +105 -0
  202. package/src/pages/visual/card/DomCard.vue +65 -0
  203. package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
  204. package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
  205. package/src/pages/visual/progress/DomProgress.vue +212 -0
  206. package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
  207. package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
  208. package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
  209. package/src/pages/visual/spinner/DomSpinner.vue +122 -0
  210. package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
  211. package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
  212. package/src/pages/visual/text-diff/textDiff.js +300 -0
  213. package/src/style.css +446 -0
@@ -0,0 +1,186 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+
4
+ defineOptions({
5
+ __doc: {
6
+ name: 'Radial progress',
7
+ tag: '<DomRadialProgress>',
8
+ description: 'A compact circular progress meter for uploads, media processing, sync status, and inline background jobs.',
9
+ icon: 'M12 3a9 9 0 1 1-6.36 2.64M12 3v5m0-5a9 9 0 0 1 9 9',
10
+ playground: {
11
+ initial: { value: 64, showValue: true, tone: 'success' },
12
+ },
13
+ },
14
+ });
15
+
16
+ const props = defineProps({
17
+ value: {
18
+ type: Number,
19
+ default: null,
20
+ _edit: { description: 'Current progress value. Leave empty for an empty meter.' },
21
+ },
22
+ max: {
23
+ type: Number,
24
+ default: 100,
25
+ _edit: { description: 'Maximum progress value.' },
26
+ },
27
+ label: {
28
+ type: String,
29
+ default: 'Progress',
30
+ _edit: { description: 'Accessible progress label.' },
31
+ },
32
+ tone: {
33
+ type: String,
34
+ default: 'primary',
35
+ _edit: {
36
+ options: ['primary', 'success', 'warning', 'danger', 'neutral'],
37
+ description: 'Progress stroke tone.',
38
+ },
39
+ },
40
+ size: {
41
+ type: [String, Number],
42
+ default: 'md',
43
+ _edit: {
44
+ options: ['sm', 'md', 'lg'],
45
+ description: 'Rendered diameter. Use a preset or a pixel number.',
46
+ },
47
+ },
48
+ showValue: {
49
+ type: Boolean,
50
+ default: true,
51
+ _edit: { description: 'Show the percentage inside the circle.' },
52
+ },
53
+ });
54
+
55
+ const safeMax = computed(getSafeMax);
56
+ const clampedValue = computed(getClampedValue);
57
+ const percent = computed(getPercent);
58
+ const rootClass = computed(getRootClass);
59
+ const rootStyle = computed(getRootStyle);
60
+ const strokeClass = computed(getStrokeClass);
61
+ const strokeStyle = computed(getStrokeStyle);
62
+
63
+ /**
64
+ * Resolves the maximum progress value while avoiding division by zero.
65
+ *
66
+ * @returns {number} Safe maximum value.
67
+ */
68
+ function getSafeMax() {
69
+ return Math.max(1, Number(props.max) || 100);
70
+ }
71
+
72
+ /**
73
+ * Resolves the current progress value inside the allowed range.
74
+ *
75
+ * @returns {number} Clamped progress value.
76
+ */
77
+ function getClampedValue() {
78
+ const value = Number(props.value);
79
+ if (!Number.isFinite(value)) return 0;
80
+ return Math.min(safeMax.value, Math.max(0, value));
81
+ }
82
+
83
+ /**
84
+ * Converts progress into a percentage.
85
+ *
86
+ * @returns {number} Rounded progress percentage.
87
+ */
88
+ function getPercent() {
89
+ return Math.round((clampedValue.value / safeMax.value) * 100);
90
+ }
91
+
92
+ /**
93
+ * Builds the root class list for the selected size preset.
94
+ *
95
+ * @returns {Array<string>} Vue class list.
96
+ */
97
+ function getRootClass() {
98
+ if (typeof props.size === 'number') return ['relative inline-grid place-items-center'];
99
+ return [
100
+ 'relative inline-grid place-items-center',
101
+ {
102
+ sm: 'size-10 text-[0.625rem]',
103
+ md: 'size-14 text-xs',
104
+ lg: 'size-20 text-sm',
105
+ }[props.size] || 'size-14 text-xs',
106
+ ];
107
+ }
108
+
109
+ /**
110
+ * Builds inline dimensions when the size prop is numeric.
111
+ *
112
+ * @returns {Record<string, string>|undefined} Inline style object.
113
+ */
114
+ function getRootStyle() {
115
+ const size = Number(props.size);
116
+ if (!Number.isFinite(size)) return undefined;
117
+ return {
118
+ width: `${Math.max(24, size)}px`,
119
+ height: `${Math.max(24, size)}px`,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Resolves the semantic stroke color class.
125
+ *
126
+ * @returns {string} Stroke class.
127
+ */
128
+ function getStrokeClass() {
129
+ return {
130
+ primary: 'text-primary',
131
+ success: 'text-success',
132
+ warning: 'text-warning',
133
+ danger: 'text-destructive',
134
+ neutral: 'text-muted-fg',
135
+ }[props.tone] || 'text-primary';
136
+ }
137
+
138
+ /**
139
+ * Builds the progress circle dash style.
140
+ *
141
+ * @returns {Record<string, string>} SVG stroke style.
142
+ */
143
+ function getStrokeStyle() {
144
+ return {
145
+ strokeDasharray: `${percent.value} 100`,
146
+ };
147
+ }
148
+ </script>
149
+
150
+ <template>
151
+ <div
152
+ :class="rootClass"
153
+ :style="rootStyle"
154
+ role="progressbar"
155
+ :aria-label="label"
156
+ :aria-valuemin="0"
157
+ :aria-valuemax="safeMax"
158
+ :aria-valuenow="clampedValue"
159
+ :aria-valuetext="`${percent}%`"
160
+ >
161
+ <svg viewBox="0 0 36 36" class="absolute inset-0 size-full -rotate-90" aria-hidden="true">
162
+ <circle
163
+ cx="18"
164
+ cy="18"
165
+ r="15"
166
+ fill="none"
167
+ stroke="currentColor"
168
+ stroke-width="3"
169
+ class="text-secondary"
170
+ />
171
+ <circle
172
+ cx="18"
173
+ cy="18"
174
+ r="15"
175
+ fill="none"
176
+ stroke="currentColor"
177
+ stroke-width="3"
178
+ stroke-linecap="round"
179
+ pathLength="100"
180
+ :class="strokeClass"
181
+ :style="strokeStyle"
182
+ />
183
+ </svg>
184
+ <span v-if="showValue" class="relative z-10 font-semibold text-canvas-fg">{{ percent }}%</span>
185
+ </div>
186
+ </template>
@@ -0,0 +1,350 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+
4
+ defineOptions({
5
+ __doc: {
6
+ name: 'Segmented progress',
7
+ tag: '<DomSegmentedProgress>',
8
+ description: 'An accessible stacked progress bar for workflows that divide a changing total across multiple states.',
9
+ icon: 'M4 7h16M4 12h16M4 17h16',
10
+ playground: {
11
+ initial: {
12
+ value: 2,
13
+ label: 'pages processed',
14
+ tone: 'warning',
15
+ size: 'lg',
16
+ segments: [
17
+ { key: 'complete', label: 'completed', value: 2, tone: 'success', color: '' },
18
+ { key: 'processing', label: 'processing', value: 1, tone: 'warning', color: 'var(--warning)' },
19
+ { key: 'queued', label: 'queued', value: 1, tone: 'neutral', color: '' },
20
+ ],
21
+ },
22
+ },
23
+ slots: [
24
+ { name: 'summary', payload: '{ value, max, percent, label }', description: 'Custom summary content shown above the track.' },
25
+ { name: 'legend-label', payload: '{ segment }', description: 'Custom label content for each legend entry.' },
26
+ ],
27
+ },
28
+ });
29
+
30
+ const props = defineProps({
31
+ segments: {
32
+ type: Array,
33
+ default: () => [],
34
+ _edit: {
35
+ component: 'DomJsonListInput',
36
+ description: 'Ordered progress states. Each segment accepts key, label, value, a semantic tone, and an optional CSS colour override.',
37
+ props: {
38
+ compact: true,
39
+ addLabel: '+ Add segment',
40
+ schema: [
41
+ { key: 'key', label: 'Key', placeholder: 'queued' },
42
+ { key: 'label', label: 'Label', placeholder: 'queued' },
43
+ { key: 'value', label: 'Value', type: 'number', default: 0 },
44
+ { key: 'tone', label: 'Tone', type: 'select', options: ['neutral', 'primary', 'success', 'warning', 'danger'] },
45
+ { key: 'color', label: 'Custom colour', placeholder: 'var(--warning)' },
46
+ ],
47
+ },
48
+ },
49
+ },
50
+ value: {
51
+ type: Number,
52
+ default: 0,
53
+ _edit: { description: 'Completed or otherwise processed item count used by the summary and accessibility value.' },
54
+ },
55
+ max: {
56
+ type: Number,
57
+ default: null,
58
+ _edit: { description: 'Maximum item count. Defaults to the sum of all segments and expands when segment values grow.' },
59
+ },
60
+ label: {
61
+ type: String,
62
+ default: 'items processed',
63
+ _edit: { description: 'Visible summary label and accessible progress label.' },
64
+ },
65
+ tone: {
66
+ type: String,
67
+ default: 'primary',
68
+ _edit: {
69
+ options: ['neutral', 'primary', 'success', 'warning', 'danger'],
70
+ description: 'Semantic tone used by the percentage summary.',
71
+ },
72
+ },
73
+ size: {
74
+ type: String,
75
+ default: 'md',
76
+ _edit: {
77
+ options: ['sm', 'md', 'lg'],
78
+ description: 'Segmented track height.',
79
+ },
80
+ },
81
+ showSummary: {
82
+ type: Boolean,
83
+ default: true,
84
+ _edit: { description: 'Show the processed count, total, label, and optional percentage.' },
85
+ },
86
+ showPercentage: {
87
+ type: Boolean,
88
+ default: true,
89
+ _edit: { description: 'Show the processed percentage in the summary.' },
90
+ },
91
+ showLegend: {
92
+ type: Boolean,
93
+ default: true,
94
+ _edit: { description: 'Show segment counts and labels below the track.' },
95
+ },
96
+ showZeroSegments: {
97
+ type: Boolean,
98
+ default: false,
99
+ _edit: { description: 'Keep zero-value states visible in the legend.' },
100
+ },
101
+ });
102
+
103
+ const normalizedSegments = computed(getNormalizedSegments);
104
+ const segmentTotal = computed(getSegmentTotal);
105
+ const resolvedMax = computed(getResolvedMax);
106
+ const safeMax = computed(getSafeMax);
107
+ const clampedValue = computed(getClampedValue);
108
+ const percent = computed(getPercent);
109
+ const renderedSegments = computed(getRenderedSegments);
110
+ const legendSegments = computed(getLegendSegments);
111
+ const trackClass = computed(getTrackClass);
112
+ const percentageClass = computed(getPercentageClass);
113
+ const ariaValueText = computed(getAriaValueText);
114
+
115
+ /**
116
+ * Normalizes consumer-provided segment records into safe render values.
117
+ *
118
+ * @returns {Array<Record<string, unknown>>} Ordered normalized segments.
119
+ */
120
+ function getNormalizedSegments() {
121
+ return props.segments.map((segment, index) => {
122
+ const source = segment && typeof segment === 'object' ? segment : {};
123
+ const numericValue = Number(source.value);
124
+ const value = Number.isFinite(numericValue) ? Math.max(0, numericValue) : 0;
125
+ const label = String(source.label || source.key || `Segment ${index + 1}`);
126
+ const color = typeof source.color === 'string' ? source.color.trim() : '';
127
+
128
+ return {
129
+ ...source,
130
+ key: source.key ?? `${label}-${index}`,
131
+ label,
132
+ value,
133
+ tone: getSegmentTone(source.tone),
134
+ color,
135
+ };
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Resolves an allowed semantic tone for a segment.
141
+ *
142
+ * @param {unknown} tone Candidate segment tone.
143
+ * @returns {'neutral'|'primary'|'success'|'warning'|'danger'} Normalized tone.
144
+ */
145
+ function getSegmentTone(tone) {
146
+ return ['neutral', 'primary', 'success', 'warning', 'danger'].includes(tone) ? tone : 'primary';
147
+ }
148
+
149
+ /**
150
+ * Sums all normalized segment values.
151
+ *
152
+ * @returns {number} Current total represented by segments.
153
+ */
154
+ function getSegmentTotal() {
155
+ return normalizedSegments.value.reduce((total, segment) => total + segment.value, 0);
156
+ }
157
+
158
+ /**
159
+ * Resolves the displayed maximum while preventing segments from overflowing it.
160
+ *
161
+ * @returns {number} Current progress maximum.
162
+ */
163
+ function getResolvedMax() {
164
+ const explicitMax = Number(props.max);
165
+ if (!Number.isFinite(explicitMax)) return segmentTotal.value;
166
+ return Math.max(0, explicitMax, segmentTotal.value);
167
+ }
168
+
169
+ /**
170
+ * Produces a non-zero maximum for percentage and width calculations.
171
+ *
172
+ * @returns {number} Safe calculation maximum.
173
+ */
174
+ function getSafeMax() {
175
+ return Math.max(1, resolvedMax.value);
176
+ }
177
+
178
+ /**
179
+ * Clamps the processed value inside the current progress range.
180
+ *
181
+ * @returns {number} Safe processed value.
182
+ */
183
+ function getClampedValue() {
184
+ const numericValue = Number(props.value);
185
+ if (!Number.isFinite(numericValue)) return 0;
186
+ return Math.min(resolvedMax.value, Math.max(0, numericValue));
187
+ }
188
+
189
+ /**
190
+ * Converts the processed value into a rounded percentage.
191
+ *
192
+ * @returns {number} Processed percentage.
193
+ */
194
+ function getPercent() {
195
+ if (resolvedMax.value === 0) return 0;
196
+ return Math.round((clampedValue.value / safeMax.value) * 100);
197
+ }
198
+
199
+ /**
200
+ * Adds semantic color classes and proportional widths to visible track segments.
201
+ *
202
+ * @returns {Array<Record<string, unknown>>} Non-zero rendered track segments.
203
+ */
204
+ function getRenderedSegments() {
205
+ return normalizedSegments.value
206
+ .filter((segment) => segment.value > 0)
207
+ .map((segment) => ({
208
+ ...segment,
209
+ class: getToneBackgroundClass(segment.tone),
210
+ style: {
211
+ width: `${(segment.value / safeMax.value) * 100}%`,
212
+ ...getSegmentColorStyle(segment.color),
213
+ },
214
+ }));
215
+ }
216
+
217
+ /**
218
+ * Selects the states that should appear in the visual legend.
219
+ *
220
+ * @returns {Array<Record<string, unknown>>} Legend segments.
221
+ */
222
+ function getLegendSegments() {
223
+ if (props.showZeroSegments) return normalizedSegments.value;
224
+ return normalizedSegments.value.filter((segment) => segment.value > 0);
225
+ }
226
+
227
+ /**
228
+ * Maps a semantic tone to the solid color used by tracks and legend dots.
229
+ *
230
+ * @param {string} tone Normalized semantic tone.
231
+ * @returns {string} Tailwind background utility.
232
+ */
233
+ function getToneBackgroundClass(tone) {
234
+ return {
235
+ neutral: 'bg-muted-fg/40',
236
+ primary: 'bg-primary',
237
+ success: 'bg-success',
238
+ warning: 'bg-warning',
239
+ danger: 'bg-destructive',
240
+ }[tone] || 'bg-primary';
241
+ }
242
+
243
+ /**
244
+ * Builds an inline background override when a segment supplies a CSS colour.
245
+ *
246
+ * Invalid CSS values are ignored by the browser, leaving the semantic tone as
247
+ * the visible fallback.
248
+ *
249
+ * @param {unknown} color Candidate CSS colour.
250
+ * @returns {Record<string, string>} Inline colour style or an empty style object.
251
+ */
252
+ function getSegmentColorStyle(color) {
253
+ if (typeof color !== 'string' || !color.trim()) return {};
254
+ return { backgroundColor: color.trim() };
255
+ }
256
+
257
+ /**
258
+ * Builds the track classes for the selected height.
259
+ *
260
+ * @returns {Array<string>} Track class list.
261
+ */
262
+ function getTrackClass() {
263
+ return [
264
+ 'flex w-full overflow-hidden rounded-full bg-secondary',
265
+ {
266
+ sm: 'h-2',
267
+ md: 'h-3',
268
+ lg: 'h-4',
269
+ }[props.size] || 'h-3',
270
+ ];
271
+ }
272
+
273
+ /**
274
+ * Maps the overall tone to a summary text color.
275
+ *
276
+ * @returns {string} Percentage text utility.
277
+ */
278
+ function getPercentageClass() {
279
+ return {
280
+ neutral: 'text-muted-fg',
281
+ primary: 'text-primary',
282
+ success: 'text-success',
283
+ warning: 'text-warning',
284
+ danger: 'text-destructive',
285
+ }[props.tone] || 'text-primary';
286
+ }
287
+
288
+ /**
289
+ * Describes both processed progress and the state distribution for assistive technology.
290
+ *
291
+ * @returns {string} Accessible progress value text.
292
+ */
293
+ function getAriaValueText() {
294
+ const distribution = normalizedSegments.value
295
+ .filter((segment) => segment.value > 0)
296
+ .map((segment) => `${segment.value} ${segment.label}`)
297
+ .join(', ');
298
+ const summary = `${clampedValue.value} of ${resolvedMax.value} ${props.label}`;
299
+ return distribution ? `${summary}. ${distribution}.` : summary;
300
+ }
301
+ </script>
302
+
303
+ <template>
304
+ <div class="w-full">
305
+ <div v-if="showSummary" class="mb-4 flex items-end justify-between gap-4">
306
+ <slot name="summary" :value="clampedValue" :max="resolvedMax" :percent="percent" :label="label">
307
+ <div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
308
+ <span class="text-2xl font-bold tabular-nums tracking-tight text-canvas-fg sm:text-3xl">
309
+ {{ clampedValue }} <span class="text-muted-fg">/</span> {{ resolvedMax }}
310
+ </span>
311
+ <span v-if="label" class="text-sm font-semibold text-muted-fg sm:text-base">{{ label }}</span>
312
+ </div>
313
+ </slot>
314
+ <span v-if="showPercentage" class="shrink-0 text-lg font-bold tabular-nums" :class="percentageClass">{{ percent }}%</span>
315
+ </div>
316
+
317
+ <div
318
+ :class="trackClass"
319
+ role="progressbar"
320
+ :aria-label="label || 'Progress'"
321
+ :aria-valuemin="0"
322
+ :aria-valuemax="safeMax"
323
+ :aria-valuenow="clampedValue"
324
+ :aria-valuetext="ariaValueText"
325
+ >
326
+ <div
327
+ v-for="segment in renderedSegments"
328
+ :key="segment.key"
329
+ class="h-full shrink-0 transition-[width] duration-300"
330
+ :class="segment.class"
331
+ :style="segment.style"
332
+ aria-hidden="true"
333
+ ></div>
334
+ </div>
335
+
336
+ <ul v-if="showLegend && legendSegments.length" class="mt-3 flex flex-wrap gap-x-6 gap-y-2 text-sm font-medium text-muted-fg" aria-label="Progress states">
337
+ <li v-for="segment in legendSegments" :key="segment.key" class="inline-flex items-center gap-1.5">
338
+ <span
339
+ class="size-2.5 shrink-0 rounded-full"
340
+ :class="getToneBackgroundClass(segment.tone)"
341
+ :style="getSegmentColorStyle(segment.color)"
342
+ aria-hidden="true"
343
+ ></span>
344
+ <slot name="legend-label" :segment="segment">
345
+ <span><span class="tabular-nums">{{ segment.value }}</span> {{ segment.label }}</span>
346
+ </slot>
347
+ </li>
348
+ </ul>
349
+ </div>
350
+ </template>
@@ -0,0 +1,174 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+
4
+ defineOptions({
5
+ __doc: {
6
+ name: 'Skeleton',
7
+ tag: '<DomSkeleton>',
8
+ description: 'A token-driven loading placeholder for cards, rows, text blocks, and avatars.',
9
+ icon: 'M5 7h14M5 12h10M5 17h7',
10
+ playground: {
11
+ initial: { variant: 'text', lines: 3, width: '100%' },
12
+ },
13
+ },
14
+ });
15
+
16
+ const props = defineProps({
17
+ as: {
18
+ type: String,
19
+ default: 'div',
20
+ _edit: { description: 'Element to render.' },
21
+ },
22
+ variant: {
23
+ type: String,
24
+ default: 'block',
25
+ _edit: {
26
+ options: ['block', 'text', 'circle'],
27
+ description: 'Placeholder shape.',
28
+ },
29
+ },
30
+ lines: {
31
+ type: Number,
32
+ default: 1,
33
+ _edit: { description: 'Number of text lines when variant is text.' },
34
+ },
35
+ width: {
36
+ type: String,
37
+ default: '',
38
+ _edit: { description: 'Optional CSS width.' },
39
+ },
40
+ height: {
41
+ type: String,
42
+ default: '',
43
+ _edit: { description: 'Optional CSS height.' },
44
+ },
45
+ label: {
46
+ type: String,
47
+ default: 'Loading',
48
+ _edit: { description: 'Screen reader label.' },
49
+ },
50
+ animated: {
51
+ type: Boolean,
52
+ default: true,
53
+ _edit: { description: 'Pulse while content is loading.' },
54
+ },
55
+ });
56
+
57
+ const lineCount = computed(getLineCount);
58
+ const rootStyle = computed(getRootStyle);
59
+ const baseClass = computed(getBaseClass);
60
+ const blockClass = computed(getBlockClass);
61
+ const lineClass = computed(getLineClass);
62
+
63
+ /**
64
+ * Resolves the number of rendered text skeleton lines.
65
+ *
66
+ * @returns {number} Text line count.
67
+ */
68
+ function getLineCount() {
69
+ return Math.max(1, Number(props.lines) || 1);
70
+ }
71
+
72
+ /**
73
+ * Builds optional inline dimensions.
74
+ *
75
+ * @returns {Record<string, string|undefined>} Inline style object.
76
+ */
77
+ function getRootStyle() {
78
+ return {
79
+ width: props.width || undefined,
80
+ height: props.height || undefined,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Builds the shared skeleton class list.
86
+ *
87
+ * @returns {Array<string|boolean>} Vue class list.
88
+ */
89
+ function getBaseClass() {
90
+ return [
91
+ 'dom-skeleton block bg-secondary ring-1 ring-border/40',
92
+ props.animated && 'dom-skeleton-animated',
93
+ ];
94
+ }
95
+
96
+ /**
97
+ * Builds the block or circle skeleton class list.
98
+ *
99
+ * @returns {Array<unknown>} Vue class list.
100
+ */
101
+ function getBlockClass() {
102
+ return [
103
+ baseClass.value,
104
+ props.variant === 'circle' ? 'size-10 rounded-full' : 'min-h-24 rounded-lg',
105
+ ];
106
+ }
107
+
108
+ /**
109
+ * Builds the text line skeleton class list.
110
+ *
111
+ * @returns {Array<unknown>} Vue class list.
112
+ */
113
+ function getLineClass() {
114
+ return [
115
+ baseClass.value,
116
+ 'h-3 rounded-full',
117
+ ];
118
+ }
119
+ </script>
120
+
121
+ <template>
122
+ <component
123
+ :is="as"
124
+ :class="variant === 'text' ? 'grid gap-2' : blockClass"
125
+ :style="rootStyle"
126
+ role="status"
127
+ :aria-label="label"
128
+ aria-busy="true"
129
+ >
130
+ <template v-if="variant === 'text'">
131
+ <span
132
+ v-for="line in lineCount"
133
+ :key="line"
134
+ :class="lineClass"
135
+ class="animate-pulse"
136
+ :style="{ width: line === lineCount && lineCount > 1 ? '72%' : '100%' }"
137
+ aria-hidden="true"
138
+ ></span>
139
+ </template>
140
+ <span v-else class="sr-only">{{ label }}</span>
141
+ </component>
142
+ </template>
143
+
144
+ <style scoped>
145
+ .dom-skeleton {
146
+ background-image: linear-gradient(
147
+ 110deg,
148
+ var(--color-secondary) 0%,
149
+ color-mix(in oklab, var(--color-secondary) 58%, var(--color-canvas) 42%) 18%,
150
+ var(--color-secondary)
151
+ );
152
+ background-size: 220% 100%;
153
+ }
154
+
155
+ .dom-skeleton-animated {
156
+ animation: dom-skeleton-shimmer 1.1s ease-in-out infinite;
157
+ }
158
+
159
+ @keyframes dom-skeleton-shimmer {
160
+ 0% {
161
+ background-position: 120% 0;
162
+ opacity: 0.50;
163
+ }
164
+
165
+ 50% {
166
+ opacity: 1;
167
+ }
168
+
169
+ 100% {
170
+ background-position: -120% 0;
171
+ opacity: 0.5;
172
+ }
173
+ }
174
+ </style>