@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,105 @@
1
+ <script setup>
2
+ import { onMounted, ref, watch } from 'vue';
3
+ import DomField from '../field/DomField.vue';
4
+ import { fieldProps } from '../field/fieldProps.js';
5
+ import { useField } from '../field/useField.js';
6
+
7
+ defineOptions({
8
+ __doc: {
9
+ name: 'Listbox',
10
+ tag: '<DomListbox>',
11
+ description: 'A styled select-like list without free-text entry.',
12
+ slots: [{ name: 'option', payload: '{ option, index }', description: 'Custom option markup.' }],
13
+ events: [
14
+ { name: 'update:modelValue', payload: '(value: string)', description: 'Emitted when selection changes.' },
15
+ { name: 'select', payload: '({ option, value })', description: 'Emitted with the full selected option.' },
16
+ ],
17
+ },
18
+ });
19
+
20
+ const props = defineProps({
21
+ ...fieldProps,
22
+ modelValue: {
23
+ type: [String, Number],
24
+ default: '',
25
+ _edit: { group: 'Control props', description: 'Selected option value.' },
26
+ },
27
+ options: {
28
+ type: Array,
29
+ required: true,
30
+ _edit: {
31
+ component: 'DomJsonListInput',
32
+ description: 'Available options.',
33
+ props: {
34
+ compact: true,
35
+ addLabel: '+ Add option',
36
+ schema: [
37
+ { key: 'label', label: 'Label', placeholder: 'Option label', default: (index) => `Option ${index + 1}` },
38
+ { key: 'value', label: 'Value', placeholder: 'option-value', default: (index) => `option-${index + 1}` },
39
+ ],
40
+ },
41
+ },
42
+ },
43
+ orientation: {
44
+ type: String,
45
+ default: 'vertical',
46
+ _edit: { group: 'Control props', options: ['vertical', 'horizontal'], description: 'Arrow key direction.' },
47
+ },
48
+ });
49
+ const emit = defineEmits(['update:modelValue', 'select', 'focus', 'blur']);
50
+ const root = ref(null);
51
+ const field = useField(props, emit, { idPrefix: 'dom-listbox' });
52
+
53
+ onMounted(async () => {
54
+ await import('../../lib/headless/listbox.js');
55
+ root.value?.addEventListener('dom:change', (event) => {
56
+ const option = props.options.find((item) => valueOf(item) === event.detail.value) || null;
57
+ field.onInput(event.detail.value);
58
+ emit('select', { value: event.detail.value, option });
59
+ });
60
+ });
61
+
62
+ watch(field.value, (value) => {
63
+ if (root.value && String(root.value.value) !== String(value ?? '')) root.value.value = value ?? '';
64
+ });
65
+
66
+ const labelOf = (option) => (option && typeof option === 'object' ? (option.label ?? option.value) : option);
67
+ const valueOf = (option) => String(option && typeof option === 'object' ? (option.value ?? option.label) : option);
68
+ </script>
69
+
70
+ <template>
71
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
72
+ <input
73
+ v-if="field.htmlName.value"
74
+ type="hidden"
75
+ :name="field.htmlName.value"
76
+ :value="field.value.value"
77
+ />
78
+ <dom-listbox
79
+ ref="root"
80
+ :id="field.id.value"
81
+ :value="field.value.value ?? ''"
82
+ :orientation="orientation"
83
+ :aria-label="label || undefined"
84
+ :aria-describedby="field.describedBy.value || undefined"
85
+ :aria-invalid="field.invalid.value || undefined"
86
+ :aria-errormessage="field.errorId.value || undefined"
87
+ class="skin-card grid gap-1 rounded-2xl p-1 data-[invalid]:border-destructive"
88
+ :class="orientation === 'horizontal' && 'grid-flow-col'"
89
+ :data-invalid="field.invalid.value ? '' : undefined"
90
+ >
91
+ <button
92
+ v-for="(option, index) in options"
93
+ :key="valueOf(option) || index"
94
+ role="option"
95
+ :data-value="valueOf(option)"
96
+ :disabled="field.disabled.value || undefined"
97
+ class="rounded-xl px-3 py-2 text-left text-sm text-canvas-fg outline-none transition hover:bg-secondary focus:bg-secondary aria-selected:bg-primary aria-selected:text-primary-fg disabled:cursor-not-allowed disabled:opacity-50"
98
+ @focus="field.onFocus"
99
+ @blur="field.onBlur"
100
+ >
101
+ <slot name="option" :option="option" :index="index">{{ labelOf(option) }}</slot>
102
+ </button>
103
+ </dom-listbox>
104
+ </DomField>
105
+ </template>
@@ -0,0 +1,147 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import DomField from '../field/DomField.vue';
4
+ import { fieldProps } from '../field/fieldProps.js';
5
+ import { useField } from '../field/useField.js';
6
+
7
+ defineOptions({
8
+ __doc: {
9
+ name: 'Money input',
10
+ tag: '<DomMoneyInput>',
11
+ description: 'A field-compatible decimal input with currency chrome for prices, budgets, invoices, and payouts.',
12
+ icon: 'M12 5v14M7 8h7a3 3 0 0 1 0 6H9a3 3 0 0 0 0 6h8',
13
+ events: [
14
+ { name: 'update:modelValue', payload: 'string | number', description: 'Fired when the amount changes.' },
15
+ { name: 'focus', payload: 'FocusEvent', description: 'Fired when the input receives focus.' },
16
+ { name: 'blur', payload: 'FocusEvent', description: 'Fired when the input loses focus.' },
17
+ ],
18
+ playground: {
19
+ initial: {
20
+ label: 'Price',
21
+ modelValue: '149.00',
22
+ currency: 'GBP',
23
+ description: 'Stored as a decimal string; upload and billing logic stay parent-owned.',
24
+ },
25
+ },
26
+ },
27
+ });
28
+
29
+ const props = defineProps({
30
+ ...fieldProps,
31
+ modelValue: {
32
+ type: [String, Number],
33
+ default: '',
34
+ _edit: { description: 'Current decimal amount.' },
35
+ },
36
+ currency: {
37
+ type: String,
38
+ default: 'GBP',
39
+ _edit: { description: 'ISO currency code used for the visual prefix.' },
40
+ },
41
+ locale: {
42
+ type: String,
43
+ default: 'en-GB',
44
+ _edit: { description: 'Locale used to resolve the currency symbol.' },
45
+ },
46
+ prefix: {
47
+ type: String,
48
+ default: '',
49
+ _edit: { description: 'Prefix override. Defaults to the currency symbol.' },
50
+ },
51
+ suffix: {
52
+ type: String,
53
+ default: '',
54
+ _edit: { description: 'Optional suffix such as per month.' },
55
+ },
56
+ min: {
57
+ type: Number,
58
+ default: null,
59
+ _edit: { description: 'Minimum numeric value.' },
60
+ },
61
+ max: {
62
+ type: Number,
63
+ default: null,
64
+ _edit: { description: 'Maximum numeric value.' },
65
+ },
66
+ step: {
67
+ type: Number,
68
+ default: 0.01,
69
+ _edit: { description: 'Native numeric step hint.' },
70
+ },
71
+ textAlign: {
72
+ type: String,
73
+ default: 'right',
74
+ _edit: {
75
+ options: ['left', 'right'],
76
+ description: 'Input text alignment.',
77
+ },
78
+ },
79
+ });
80
+
81
+ const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
82
+ const field = useField(props, emit, { idPrefix: 'dom-money-input' });
83
+
84
+ const prefixText = computed(() => props.prefix || currencySymbol(props.currency, props.locale));
85
+ const inputAttrs = computed(() => ({
86
+ ...field.inputAttrs.value,
87
+ inputmode: 'decimal',
88
+ autocomplete: 'off',
89
+ 'aria-valuemin': props.min ?? undefined,
90
+ 'aria-valuemax': props.max ?? undefined,
91
+ }));
92
+ const controlClass = computed(() => [
93
+ 'flex h-10 w-full overflow-hidden rounded-lg border border-input bg-canvas text-sm shadow-sm transition focus-within:ring-2 focus-within:ring-ring/50',
94
+ field.invalid.value && 'border-destructive',
95
+ (field.disabled.value || field.readOnly.value) && 'opacity-60',
96
+ ]);
97
+ const inputClass = computed(() => [
98
+ 'min-w-0 flex-1 bg-transparent px-3 text-canvas-fg outline-none placeholder:text-muted-fg disabled:cursor-not-allowed',
99
+ props.textAlign === 'right' ? 'text-right' : 'text-left',
100
+ ]);
101
+
102
+ function currencySymbol(currency, locale) {
103
+ try {
104
+ const parts = new Intl.NumberFormat(locale || undefined, {
105
+ style: 'currency',
106
+ currency: currency || 'GBP',
107
+ currencyDisplay: 'narrowSymbol',
108
+ }).formatToParts(0);
109
+ return parts.find((part) => part.type === 'currency')?.value || currency || '';
110
+ } catch {
111
+ return currency || '';
112
+ }
113
+ }
114
+
115
+ function onInput(event) {
116
+ field.onInput(event.target.value);
117
+ }
118
+ </script>
119
+
120
+ <template>
121
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
122
+ <div :class="controlClass">
123
+ <span
124
+ v-if="prefixText"
125
+ class="flex shrink-0 items-center border-r border-border bg-secondary px-3 text-sm font-medium text-muted-fg"
126
+ aria-hidden="true"
127
+ >
128
+ {{ prefixText }}
129
+ </span>
130
+ <input
131
+ v-bind="inputAttrs"
132
+ type="text"
133
+ :class="inputClass"
134
+ :placeholder="placeholder || '0.00'"
135
+ @input="onInput"
136
+ @focus="field.onFocus"
137
+ @blur="field.onBlur"
138
+ />
139
+ <span
140
+ v-if="suffix"
141
+ class="flex shrink-0 items-center border-l border-border bg-secondary px-3 text-sm font-medium text-muted-fg"
142
+ >
143
+ {{ suffix }}
144
+ </span>
145
+ </div>
146
+ </DomField>
147
+ </template>
@@ -0,0 +1,67 @@
1
+ <script setup>
2
+ import DomField from '../field/DomField.vue';
3
+ import { fieldProps } from '../field/fieldProps.js';
4
+ import { useField } from '../field/useField.js';
5
+
6
+ defineOptions({
7
+ __doc: {
8
+ name: 'Native select',
9
+ tag: '<DomNativeSelect>',
10
+ description: 'A styled native select for cases where browser-native picker behaviour is intentionally preferred.',
11
+ events: [
12
+ { name: 'update:modelValue', payload: 'string', description: 'Fired when the selected option changes.' },
13
+ ],
14
+ },
15
+ });
16
+
17
+ const props = defineProps({
18
+ ...fieldProps,
19
+ options: {
20
+ type: Array,
21
+ default: () => [],
22
+ _edit: {
23
+ component: 'DomJsonListInput',
24
+ props: {
25
+ description: 'Options can be strings or objects with label/value.',
26
+ compact: true,
27
+ schema: [
28
+ { key: 'label', label: 'Label', placeholder: 'Team workspace' },
29
+ { key: 'value', label: 'Value', placeholder: 'team' },
30
+ ],
31
+ },
32
+ },
33
+ },
34
+ placeholder: {
35
+ type: String,
36
+ default: 'Select an option',
37
+ _edit: { description: 'Disabled placeholder option.' },
38
+ },
39
+ });
40
+
41
+ const emit = defineEmits(['update:modelValue']);
42
+ const labelOf = (option) => (option && typeof option === 'object' ? (option.label ?? option.value) : option);
43
+ const valueOf = (option) => (option && typeof option === 'object' ? (option.value ?? option.label) : option);
44
+ const field = useField(props, emit, { idPrefix: 'dom-native-select' });
45
+ </script>
46
+
47
+ <template>
48
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
49
+ <div class="relative">
50
+ <select
51
+ v-bind="field.inputAttrs.value"
52
+ class="skin-input h-10 appearance-none pr-10"
53
+ @change="field.onInput($event.target.value)"
54
+ @focus="field.onFocus"
55
+ @blur="field.onBlur"
56
+ >
57
+ <option v-if="placeholder" disabled value="">{{ placeholder }}</option>
58
+ <option v-for="option in options" :key="valueOf(option)" :value="valueOf(option)">
59
+ {{ labelOf(option) }}
60
+ </option>
61
+ </select>
62
+ <svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-fg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
63
+ <path d="M7 10l5 5 5-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
64
+ </svg>
65
+ </div>
66
+ </DomField>
67
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup>
2
+ import DomField from '../field/DomField.vue';
3
+ import { fieldProps } from '../field/fieldProps.js';
4
+ import { useField } from '../field/useField.js';
5
+
6
+ const props = defineProps({
7
+ ...fieldProps,
8
+ modelValue: {
9
+ type: [Number, String],
10
+ default: '',
11
+ _edit: { description: 'Current numeric value.' },
12
+ },
13
+ min: Number,
14
+ max: Number,
15
+ step: Number,
16
+ });
17
+ const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
18
+ const field = useField(props, emit, { idPrefix: 'dom-number-input' });
19
+
20
+ /**
21
+ * Normalizes numeric input text while preserving a deliberately empty value.
22
+ *
23
+ * @param {string|number} value Current input value.
24
+ * @returns {string|number} Parsed number, empty string, or original invalid value.
25
+ */
26
+ function toNumber(value) {
27
+ if (value === '') return '';
28
+ const number = Number(value);
29
+ return Number.isNaN(number) ? value : number;
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
35
+ <input
36
+ v-bind="field.inputAttrs.value"
37
+ type="number"
38
+ :min="min"
39
+ :max="max"
40
+ :step="step"
41
+ class="skin-input h-10"
42
+ @input="field.onInput(toNumber($event.target.value))"
43
+ @focus="field.onFocus"
44
+ @blur="field.onBlur"
45
+ />
46
+ </DomField>
47
+ </template>
@@ -0,0 +1,218 @@
1
+ <script setup>
2
+ import { computed, onBeforeUnmount, ref, watch } from 'vue';
3
+ import DomAlert from '../../visual/alert/DomAlert.vue';
4
+ import DomField from '../field/DomField.vue';
5
+ import { fieldProps } from '../field/fieldProps.js';
6
+ import { useField } from '../field/useField.js';
7
+ import DomPasswordStrength from '../password-strength/DomPasswordStrength.vue';
8
+ import { checkCompromisedPassword } from './pwnedPasswords.js';
9
+
10
+ defineOptions({
11
+ __doc: {
12
+ name: 'Password input',
13
+ tag: '<DomPasswordInput>',
14
+ description: 'A styled password field with show/hide control, strength feedback, and an optional compromised-password warning.',
15
+ events: [
16
+ { name: 'update:modelValue', payload: 'string', description: 'Fired when the password text changes.' },
17
+ ],
18
+ },
19
+ });
20
+
21
+ const props = defineProps({
22
+ ...fieldProps,
23
+ label: {
24
+ type: String,
25
+ default: 'Password',
26
+ _edit: { description: 'Visible field label.' },
27
+ },
28
+ description: {
29
+ type: String,
30
+ default: '',
31
+ _edit: { description: 'Optional helper copy below the label.' },
32
+ },
33
+ placeholder: {
34
+ type: String,
35
+ default: 'Enter password',
36
+ _edit: { description: 'Placeholder text shown before entry.' },
37
+ },
38
+ showStrength: {
39
+ type: Boolean,
40
+ default: true,
41
+ _edit: { description: 'Show a strength meter below the field.' },
42
+ },
43
+ compromised: {
44
+ type: Boolean,
45
+ default: false,
46
+ _edit: { description: 'Show a warning when the current password has been found in a breach.' },
47
+ },
48
+ checkCompromised: {
49
+ type: Boolean,
50
+ default: false,
51
+ _edit: { description: 'Check the password against the Have I Been Pwned Pwned Passwords API.' },
52
+ },
53
+ compromisedCheckDelay: {
54
+ type: Number,
55
+ default: 600,
56
+ _edit: { description: 'Delay in milliseconds before checking the Pwned Passwords API after typing.' },
57
+ },
58
+ compromisedMessage: {
59
+ type: String,
60
+ default: 'Choose a different password that you have not used elsewhere.',
61
+ _edit: { description: 'Guidance shown when compromised is true.' },
62
+ },
63
+ compromisedTitle: {
64
+ type: String,
65
+ default: 'Password found in a data breach',
66
+ _edit: { description: 'Heading shown above the compromised-password guidance.' },
67
+ },
68
+ });
69
+
70
+ const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
71
+ const visible = ref(false);
72
+ const field = useField(props, emit, { idPrefix: 'dom-password-input' });
73
+ const compromisedCheck = ref({
74
+ pending: false,
75
+ compromised: false,
76
+ count: 0,
77
+ error: null,
78
+ });
79
+ const isCompromised = computed(() => props.compromised || (props.checkCompromised && compromisedCheck.value.compromised));
80
+ const passwordFieldAttrs = computed(() => ({
81
+ ...field.fieldAttrs.value,
82
+ invalid: field.invalid.value || isCompromised.value,
83
+ }));
84
+ const compromisedWarning = computed(() => {
85
+ if (!props.checkCompromised || !compromisedCheck.value.compromised) return props.compromisedMessage;
86
+ const count = compromisedCheck.value.count;
87
+ const occurrences = count === 1 ? 'time' : 'times';
88
+ return `This password appears ${count.toLocaleString()} ${occurrences} in known breach data. ${props.compromisedMessage}`;
89
+ });
90
+
91
+ let compromisedCheckTimer;
92
+ let compromisedCheckController;
93
+
94
+ const resetCompromisedCheck = () => {
95
+ clearTimeout(compromisedCheckTimer);
96
+ compromisedCheckController?.abort();
97
+ compromisedCheckController = null;
98
+ compromisedCheck.value = {
99
+ pending: false,
100
+ compromised: false,
101
+ count: 0,
102
+ error: null,
103
+ };
104
+ };
105
+
106
+ const runCompromisedCheck = async (password) => {
107
+ compromisedCheckController?.abort();
108
+ const controller = new AbortController();
109
+ compromisedCheckController = controller;
110
+ compromisedCheck.value = {
111
+ pending: true,
112
+ compromised: false,
113
+ count: 0,
114
+ error: null,
115
+ };
116
+
117
+ try {
118
+ const result = await checkCompromisedPassword(password, { signal: controller.signal });
119
+ if (controller.signal.aborted) return;
120
+ compromisedCheck.value = {
121
+ pending: false,
122
+ compromised: result.compromised,
123
+ count: result.count,
124
+ error: null,
125
+ };
126
+ } catch (error) {
127
+ if (controller.signal.aborted) return;
128
+ compromisedCheck.value = {
129
+ pending: false,
130
+ compromised: false,
131
+ count: 0,
132
+ error,
133
+ };
134
+ }
135
+ };
136
+
137
+ if (typeof window !== 'undefined') {
138
+ watch(
139
+ () => [props.checkCompromised, field.value.value, props.compromisedCheckDelay],
140
+ ([shouldCheck, password, delay]) => {
141
+ clearTimeout(compromisedCheckTimer);
142
+ compromisedCheckController?.abort();
143
+
144
+ if (!shouldCheck || !password) {
145
+ resetCompromisedCheck();
146
+ return;
147
+ }
148
+
149
+ compromisedCheck.value = {
150
+ pending: true,
151
+ compromised: false,
152
+ count: 0,
153
+ error: null,
154
+ };
155
+ compromisedCheckTimer = setTimeout(() => runCompromisedCheck(password), Math.max(0, delay));
156
+ },
157
+ { immediate: true },
158
+ );
159
+
160
+ onBeforeUnmount(resetCompromisedCheck);
161
+ }
162
+
163
+ </script>
164
+
165
+ <template>
166
+ <DomField v-bind="passwordFieldAttrs" :chrome="chrome">
167
+ <div class="dom-password-input__body">
168
+ <div class="relative">
169
+ <input
170
+ v-bind="field.inputAttrs.value"
171
+ :type="visible ? 'text' : 'password'"
172
+ :aria-invalid="field.invalid.value || isCompromised || undefined"
173
+ class="skin-input h-10 pr-11"
174
+ @input="field.onInput($event.target.value)"
175
+ @focus="field.onFocus"
176
+ @blur="field.onBlur"
177
+ />
178
+ <button
179
+ type="button"
180
+ class="absolute inset-y-1 right-1 grid size-8 place-items-center rounded-md text-muted-fg transition hover:bg-secondary hover:text-secondary-fg focus-visible:outline-2 focus-visible:outline-ring disabled:opacity-50"
181
+ :disabled="field.disabled.value"
182
+ :aria-label="visible ? 'Hide password' : 'Show password'"
183
+ @click="visible = !visible"
184
+ >
185
+ <svg v-if="visible" class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
186
+ <path d="M3 3l18 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
187
+ <path d="M10.6 10.6a2 2 0 0 0 2.8 2.8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
188
+ <path d="M8.5 5.4A9.8 9.8 0 0 1 12 4.75c4.6 0 7.7 3.4 9 7.25a11 11 0 0 1-2.2 3.7M6.2 6.9A11 11 0 0 0 3 12c1.3 3.85 4.4 7.25 9 7.25 1.2 0 2.3-.23 3.3-.64" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
189
+ </svg>
190
+ <svg v-else class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
191
+ <path d="M3 12c1.3-3.85 4.4-7.25 9-7.25s7.7 3.4 9 7.25c-1.3 3.85-4.4 7.25-9 7.25S4.3 15.85 3 12Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
192
+ <path d="M12 14.75a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z" stroke="currentColor" stroke-width="1.8" />
193
+ </svg>
194
+ </button>
195
+ </div>
196
+
197
+ <DomPasswordStrength v-if="showStrength" :model-value="field.value.value" class="mt-3" />
198
+ <transition
199
+ enter-active-class="transition duration-300 ease"
200
+ enter-from-class="max-h-0 -translate-y-2 opacity-0"
201
+ enter-to-class="max-h-40 translate-y-0 opacity-100"
202
+ leave-active-class="transition duration-200 ease"
203
+ leave-from-class="max-h-40 translate-y-0 opacity-100"
204
+ leave-to-class="max-h-0 -translate-y-2 opacity-0"
205
+ >
206
+ <DomAlert
207
+ v-if="isCompromised"
208
+ class="dom-password-input__warning mt-3 overflow-hidden"
209
+ tone="danger"
210
+ variant="soft"
211
+ :title="compromisedTitle"
212
+ :description="compromisedWarning"
213
+ style="transition: max-height 0.3s cubic-bezier(0.4,0,0.2,1), opacity 0.3s cubic-bezier(0.4,0,0.2,1), transform 0.3s cubic-bezier(0.4,0,0.2,1);"
214
+ />
215
+ </transition>
216
+ </div>
217
+ </DomField>
218
+ </template>
@@ -0,0 +1,47 @@
1
+ const PWNED_PASSWORDS_RANGE_ENDPOINT = 'https://api.pwnedpasswords.com/range/';
2
+
3
+ const byteToHex = (byte) => byte.toString(16).padStart(2, '0');
4
+
5
+ export async function sha1PasswordHash(password) {
6
+ if (!globalThis.crypto?.subtle) {
7
+ throw new Error('Web Crypto API is required to check compromised passwords.');
8
+ }
9
+
10
+ const encoded = new TextEncoder().encode(password);
11
+ const digest = await globalThis.crypto.subtle.digest('SHA-1', encoded);
12
+ return Array.from(new Uint8Array(digest), byteToHex).join('').toUpperCase();
13
+ }
14
+
15
+ export async function checkCompromisedPassword(password, { signal } = {}) {
16
+ const value = `${password ?? ''}`;
17
+ if (!value) {
18
+ return {
19
+ compromised: false,
20
+ count: 0,
21
+ };
22
+ }
23
+
24
+ const hash = await sha1PasswordHash(value);
25
+ const prefix = hash.slice(0, 5);
26
+ const suffix = hash.slice(5);
27
+ const response = await fetch(`${PWNED_PASSWORDS_RANGE_ENDPOINT}${prefix}`, {
28
+ headers: {
29
+ 'Add-Padding': 'true',
30
+ },
31
+ signal,
32
+ });
33
+
34
+ if (!response.ok) {
35
+ throw new Error(`Pwned Passwords request failed with ${response.status}.`);
36
+ }
37
+
38
+ const match = (await response.text())
39
+ .split(/\r?\n/)
40
+ .find((line) => line.startsWith(`${suffix}:`));
41
+ const count = match ? Number.parseInt(match.split(':')[1], 10) || 0 : 0;
42
+
43
+ return {
44
+ compromised: count > 0,
45
+ count,
46
+ };
47
+ }