frappe-ui 1.0.0-beta.0 → 1.0.0-beta.1

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 (216) hide show
  1. package/frappe/Link/Link.api.md +114 -0
  2. package/frappe/Link/Link.cy.ts +215 -0
  3. package/frappe/Link/Link.md +38 -0
  4. package/frappe/Link/Link.vue +101 -42
  5. package/frappe/Link/index.ts +1 -1
  6. package/frappe/Link/stories/Creatable.vue +96 -0
  7. package/frappe/Link/stories/Filters.vue +67 -0
  8. package/frappe/Link/stories/Labeling.vue +32 -0
  9. package/frappe/Link/stories/MemberPicker.vue +37 -0
  10. package/frappe/Link/stories/Simple.vue +21 -0
  11. package/frappe/Link/stories/Suffix.vue +118 -0
  12. package/frappe/Link/stories/_mock.ts +151 -0
  13. package/frappe/Link/types.ts +20 -8
  14. package/package.json +9 -4
  15. package/src/components/Combobox/Combobox.api.md +2 -2
  16. package/src/components/Combobox/Combobox.md +5 -0
  17. package/src/components/Combobox/Combobox.vue +45 -37
  18. package/src/components/Combobox/ComboboxResults.vue +0 -8
  19. package/src/components/Combobox/stories/Footer.vue +67 -0
  20. package/src/components/Combobox/types.ts +21 -2
  21. package/src/components/ListView/ListGroupHeader.vue +1 -1
  22. package/src/components/Rating/Rating.api.md +9 -2
  23. package/src/components/Rating/Rating.cy.ts +13 -5
  24. package/src/components/Rating/Rating.md +13 -57
  25. package/src/components/Rating/Rating.vue +33 -30
  26. package/src/components/Rating/stories/CustomIcon.vue +16 -3
  27. package/src/components/Rating/stories/CustomSlot.vue +1 -1
  28. package/src/components/Rating/stories/Labeling.vue +1 -1
  29. package/src/components/Rating/stories/States.vue +1 -1
  30. package/src/components/Rating/types.ts +7 -1
  31. package/src/components/Select/Select.api.md +2 -2
  32. package/src/components/Select/Select.md +5 -0
  33. package/src/components/Select/Select.vue +28 -19
  34. package/src/components/Select/stories/Footer.vue +63 -0
  35. package/src/components/Select/types.ts +14 -2
  36. package/src/components/TextEditor/TextEditor.vue +12 -0
  37. package/src/components/Toast/Toast.md +5 -1
  38. package/src/components/Toast/ToastProvider.vue +1 -1
  39. package/src/components/Toast/stories/Async.vue +28 -7
  40. package/src/components/Toast/toast.ts +0 -42
  41. package/src/molecules/editor/Editor.cy.ts +141 -0
  42. package/src/molecules/editor/Editor.test.ts +265 -0
  43. package/src/molecules/editor/Editor.vue +108 -0
  44. package/src/molecules/editor/EditorBubbleMenu.vue +45 -0
  45. package/src/molecules/editor/EditorContent.vue +105 -0
  46. package/src/molecules/editor/EditorFixedMenu.vue +20 -0
  47. package/src/molecules/editor/EditorFloatingMenu.vue +45 -0
  48. package/src/molecules/editor/MenuItems.vue +167 -0
  49. package/src/molecules/editor/SuggestionExtension.ts +53 -0
  50. package/src/molecules/editor/commands.ts +185 -0
  51. package/src/molecules/editor/components/ImageViewerModal.vue +238 -0
  52. package/src/molecules/editor/components/InsertImage.vue +54 -0
  53. package/src/molecules/editor/components/InsertVideo.vue +14 -0
  54. package/src/molecules/editor/components/MediaNodeView.vue +254 -0
  55. package/src/molecules/editor/components/MediaToolbar.vue +107 -0
  56. package/src/molecules/editor/components/font-color/ColorSwatchGrid.vue +45 -0
  57. package/src/molecules/editor/components/font-color/fontColorController.ts +76 -0
  58. package/src/molecules/editor/components/font-color/swatches.ts +92 -0
  59. package/src/molecules/editor/components/image-viewer/ImageViewerControlsBar.vue +123 -0
  60. package/src/molecules/editor/components/image-viewer/imageViewerDownload.ts +46 -0
  61. package/src/molecules/editor/components/image-viewer/imageViewerKeymap.ts +61 -0
  62. package/src/molecules/editor/components/media-node-view-controller.ts +108 -0
  63. package/src/molecules/editor/components/media-node-view-utils.test.ts +71 -0
  64. package/src/molecules/editor/components/media-node-view-utils.ts +77 -0
  65. package/src/molecules/editor/composables/useControlsAutoHide.ts +66 -0
  66. package/src/molecules/editor/composables/useElementSize.ts +50 -0
  67. package/src/molecules/editor/composables/useFloatingPopup.ts +122 -0
  68. package/src/molecules/editor/composables/useFullscreen.ts +47 -0
  69. package/src/molecules/editor/composables/useNamedColorState.ts +74 -0
  70. package/src/molecules/editor/composables/useNodeViewEditable.ts +35 -0
  71. package/src/molecules/editor/composables/useNodeViewResize.test.ts +137 -0
  72. package/src/molecules/editor/composables/useNodeViewResize.ts +132 -0
  73. package/src/molecules/editor/composables/useObjectUrl.ts +74 -0
  74. package/src/molecules/editor/composables/useScopedFileDrop.ts +86 -0
  75. package/src/molecules/editor/composables/useScrollContainer.ts +34 -0
  76. package/src/molecules/editor/composables/useSuggestionList.ts +66 -0
  77. package/src/molecules/editor/composables/useTocActiveHeading.ts +96 -0
  78. package/src/molecules/editor/composables/useTocAnchors.ts +83 -0
  79. package/src/molecules/editor/editor-context.ts +37 -0
  80. package/src/molecules/editor/extensions/code-block/CodeBlockComponent.css +220 -0
  81. package/src/molecules/editor/extensions/code-block/CodeBlockComponent.vue +55 -0
  82. package/src/molecules/editor/extensions/code-block/code-block-indent.test.ts +44 -0
  83. package/src/molecules/editor/extensions/code-block/code-block-indent.ts +60 -0
  84. package/src/molecules/editor/extensions/code-block/code-block.ts +85 -0
  85. package/src/molecules/editor/extensions/code-block.ts +4 -0
  86. package/src/molecules/editor/extensions/color/color-extension.ts +112 -0
  87. package/src/molecules/editor/extensions/color/color-styles.css +28 -0
  88. package/src/molecules/editor/extensions/color/index.ts +2 -0
  89. package/src/molecules/editor/extensions/content-paste/content-paste-extension.ts +109 -0
  90. package/src/molecules/editor/extensions/content-paste/index.ts +4 -0
  91. package/src/molecules/editor/extensions/content-paste/media-src-utils.ts +53 -0
  92. package/src/molecules/editor/extensions/content-paste/paste-html-utils.ts +71 -0
  93. package/src/molecules/editor/extensions/content-paste/paste-image-controller.test.ts +86 -0
  94. package/src/molecules/editor/extensions/content-paste/paste-image-controller.ts +95 -0
  95. package/src/molecules/editor/extensions/content-paste/paste-markdown-utils.ts +20 -0
  96. package/src/molecules/editor/extensions/copy-styles/copy-styles-extension.ts +146 -0
  97. package/src/molecules/editor/extensions/copy-styles/index.ts +5 -0
  98. package/src/molecules/editor/extensions/copy-styles/style-clipboard-utils.ts +134 -0
  99. package/src/molecules/editor/extensions/emoji/EmojiList.vue +52 -0
  100. package/src/molecules/editor/extensions/emoji/emoji-extension.ts +54 -0
  101. package/src/molecules/editor/extensions/emoji/emojis.json +7422 -0
  102. package/src/molecules/editor/extensions/heading/heading-ids.ts +92 -0
  103. package/src/molecules/editor/extensions/heading/heading.ts +20 -0
  104. package/src/molecules/editor/extensions/highlight/highlight-extension.ts +214 -0
  105. package/src/molecules/editor/extensions/highlight/highlight-styles.css +34 -0
  106. package/src/molecules/editor/extensions/highlight/index.ts +2 -0
  107. package/src/molecules/editor/extensions/iframe/IframeInsertDialog.vue +79 -0
  108. package/src/molecules/editor/extensions/iframe/IframeNodeView.vue +218 -0
  109. package/src/molecules/editor/extensions/iframe/iframe-allowlist.ts +79 -0
  110. package/src/molecules/editor/extensions/iframe/iframe-commands.ts +88 -0
  111. package/src/molecules/editor/extensions/iframe/iframe-embed-utils.ts +247 -0
  112. package/src/molecules/editor/extensions/iframe/iframe-extension.test.ts +72 -0
  113. package/src/molecules/editor/extensions/iframe/iframe-extension.ts +180 -0
  114. package/src/molecules/editor/extensions/iframe/iframe-paste-handler.ts +90 -0
  115. package/src/molecules/editor/extensions/iframe/iframeInsertDialogController.ts +44 -0
  116. package/src/molecules/editor/extensions/iframe/index.ts +22 -0
  117. package/src/molecules/editor/extensions/iframe/parseIframeEmbed.ts +49 -0
  118. package/src/molecules/editor/extensions/iframe/useIframeDialog.ts +127 -0
  119. package/src/molecules/editor/extensions/image/image-engine.ts +22 -0
  120. package/src/molecules/editor/extensions/image/image-extension.ts +278 -0
  121. package/src/molecules/editor/extensions/image/index.ts +5 -0
  122. package/src/molecules/editor/extensions/image-group/ImageGroupGrid.vue +82 -0
  123. package/src/molecules/editor/extensions/image-group/ImageGroupGridCell.vue +126 -0
  124. package/src/molecules/editor/extensions/image-group/ImageGroupNodeView.vue +169 -0
  125. package/src/molecules/editor/extensions/image-group/ImageGroupUploadDialog.vue +294 -0
  126. package/src/molecules/editor/extensions/image-group/image-group-commands.ts +187 -0
  127. package/src/molecules/editor/extensions/image-group/image-group-extension.ts +108 -0
  128. package/src/molecules/editor/extensions/image-group/image-group-utils.test.ts +99 -0
  129. package/src/molecules/editor/extensions/image-group/image-group-utils.ts +89 -0
  130. package/src/molecules/editor/extensions/image-group/imageGroupDialogController.ts +47 -0
  131. package/src/molecules/editor/extensions/image-group/index.ts +6 -0
  132. package/src/molecules/editor/extensions/image-group/useImageGroupDialog.ts +230 -0
  133. package/src/molecules/editor/extensions/image-group/useStrayDropGuard.ts +34 -0
  134. package/src/molecules/editor/extensions/image-viewer/collectImages.ts +39 -0
  135. package/src/molecules/editor/extensions/image-viewer/image-viewer-extension.ts +42 -0
  136. package/src/molecules/editor/extensions/image-viewer/imageViewerController.ts +73 -0
  137. package/src/molecules/editor/extensions/image-viewer/imageViewerStyle.ts +53 -0
  138. package/src/molecules/editor/extensions/image-viewer/index.ts +2 -0
  139. package/src/molecules/editor/extensions/link/InsertLink.vue +13 -0
  140. package/src/molecules/editor/extensions/link/LinkEditorPopup.vue +110 -0
  141. package/src/molecules/editor/extensions/link/clear-link-on-boundary-plugin.ts +65 -0
  142. package/src/molecules/editor/extensions/link/index.ts +2 -0
  143. package/src/molecules/editor/extensions/link/link-click-plugin.ts +58 -0
  144. package/src/molecules/editor/extensions/link/link-commands.ts +148 -0
  145. package/src/molecules/editor/extensions/link/link-extension.ts +58 -0
  146. package/src/molecules/editor/extensions/link/link-paste-plugin.ts +59 -0
  147. package/src/molecules/editor/extensions/link/link-popup-controller.ts +106 -0
  148. package/src/molecules/editor/extensions/link/link-shortcut-plugin.ts +42 -0
  149. package/src/molecules/editor/extensions/mention/index.ts +1 -0
  150. package/src/molecules/editor/extensions/mention/mention-extension.ts +198 -0
  151. package/src/molecules/editor/extensions/mention/style.css +11 -0
  152. package/src/molecules/editor/extensions/shared/color-palette.test.ts +136 -0
  153. package/src/molecules/editor/extensions/shared/color-palette.ts +118 -0
  154. package/src/molecules/editor/extensions/shared/color-parse.ts +137 -0
  155. package/src/molecules/editor/extensions/shared/color-style.ts +119 -0
  156. package/src/molecules/editor/extensions/shared/color-utils.ts +13 -0
  157. package/src/molecules/editor/extensions/shared/file-picker.ts +16 -0
  158. package/src/molecules/editor/extensions/shared/heading-scope.ts +103 -0
  159. package/src/molecules/editor/extensions/shared/heading-tree-utils.ts +65 -0
  160. package/src/molecules/editor/extensions/shared/lowlight-languages.ts +42 -0
  161. package/src/molecules/editor/extensions/shared/media-dimensions.ts +60 -0
  162. package/src/molecules/editor/extensions/shared/media-node-ops.ts +144 -0
  163. package/src/molecules/editor/extensions/shared/media-plugin.ts +166 -0
  164. package/src/molecules/editor/extensions/shared/media-upload-engine.test.ts +172 -0
  165. package/src/molecules/editor/extensions/shared/media-upload-engine.ts +288 -0
  166. package/src/molecules/editor/extensions/shared/media-upload-state.ts +36 -0
  167. package/src/molecules/editor/extensions/shared/media-upload-types.ts +105 -0
  168. package/src/molecules/editor/extensions/shared/node-view.test.ts +43 -0
  169. package/src/molecules/editor/extensions/shared/node-view.ts +98 -0
  170. package/src/molecules/editor/extensions/shared/suggestion-helpers.ts +63 -0
  171. package/src/molecules/editor/extensions/shared/suggestion-renderer.ts +146 -0
  172. package/src/molecules/editor/extensions/shared/suggestion-types.ts +38 -0
  173. package/src/molecules/editor/extensions/shared/toggle-code-shortcut.ts +14 -0
  174. package/src/molecules/editor/extensions/shared/upload-id.ts +12 -0
  175. package/src/molecules/editor/extensions/shared/upload-types.ts +26 -0
  176. package/src/molecules/editor/extensions/shared/url-safety.ts +102 -0
  177. package/src/molecules/editor/extensions/slash-commands/SlashCommandsList.vue +55 -0
  178. package/src/molecules/editor/extensions/slash-commands/slash-commands-extension.ts +132 -0
  179. package/src/molecules/editor/extensions/suggestion/SuggestionList.vue +106 -0
  180. package/src/molecules/editor/extensions/suggestion/SuggestionListItem.vue +46 -0
  181. package/src/molecules/editor/extensions/suggestion/createSuggestionExtension.ts +84 -0
  182. package/src/molecules/editor/extensions/suggestion/index.ts +6 -0
  183. package/src/molecules/editor/extensions/tag/index.ts +7 -0
  184. package/src/molecules/editor/extensions/tag/tag-extension.ts +200 -0
  185. package/src/molecules/editor/extensions/toc-node/TocItem.vue +49 -0
  186. package/src/molecules/editor/extensions/toc-node/TocNodeView.vue +103 -0
  187. package/src/molecules/editor/extensions/toc-node/index.ts +3 -0
  188. package/src/molecules/editor/extensions/toc-node/toc-node-extension.ts +61 -0
  189. package/src/molecules/editor/extensions/toc-node/toc-render.ts +67 -0
  190. package/src/molecules/editor/extensions/toc-node/toc-scroll-controller.ts +89 -0
  191. package/src/molecules/editor/extensions/video/index.ts +5 -0
  192. package/src/molecules/editor/extensions/video/video-config.ts +28 -0
  193. package/src/molecules/editor/extensions/video/video-extension.ts +240 -0
  194. package/src/molecules/editor/extensions.ts +145 -0
  195. package/src/molecules/editor/index.ts +34 -0
  196. package/src/molecules/editor/kits.test.ts +148 -0
  197. package/src/molecules/editor/kits.ts +281 -0
  198. package/src/molecules/editor/menu.test.ts +294 -0
  199. package/src/molecules/editor/menu.ts +225 -0
  200. package/src/molecules/editor/stories/Comment.vue +105 -0
  201. package/src/molecules/editor/stories/Inline.vue +28 -0
  202. package/src/molecules/editor/stories/Primitives.vue +73 -0
  203. package/src/molecules/editor/stories/RichText.vue +129 -0
  204. package/src/molecules/editor/style.css +152 -0
  205. package/src/molecules/editor/useEditor.test.ts +237 -0
  206. package/src/molecules/editor/useEditor.ts +135 -0
  207. package/src/resources/resources.js +11 -1
  208. package/src/resources/resources.test.ts +45 -0
  209. package/src/utils/request.js +1 -0
  210. package/tailwind/plugin.js +8 -1
  211. package/tailwind/preset.js +5 -0
  212. package/src/components/Rating/stories/Clearable.vue +0 -29
  213. package/src/components/Rating/stories/CustomColor.vue +0 -47
  214. package/src/components/Rating/stories/HalfStepCustomColor.vue +0 -27
  215. package/src/components/Rating/stories/LegacyRatingFrom.vue +0 -13
  216. package/src/components/Rating/stories/Max.vue +0 -10
@@ -0,0 +1,114 @@
1
+ <!-- Auto Generated by scripts/propsgen.ts -->
2
+ <script setup>
3
+ import PropsTable from '@/components/Docs/PropsTable.vue'
4
+ import SlotsTable from '@/components/Docs/SlotsTable.vue'
5
+ import EmitsTable from '@/components/Docs/EmitsTable.vue'
6
+
7
+ const propsData = [
8
+ {
9
+ name: 'doctype',
10
+ description: '',
11
+ required: true,
12
+ type: 'string'
13
+ },
14
+ {
15
+ name: 'filters',
16
+ description: '',
17
+ required: false,
18
+ type: 'Record<string, unknown>',
19
+ default: '{}'
20
+ },
21
+ {
22
+ name: 'creatable',
23
+ description: '',
24
+ required: false,
25
+ type: 'boolean',
26
+ default: 'false'
27
+ },
28
+ {
29
+ name: 'disabled',
30
+ description: '',
31
+ required: false,
32
+ type: 'boolean',
33
+ default: 'false'
34
+ },
35
+ {
36
+ name: 'label',
37
+ description: 'Label rendered above (or beside, for binary controls) the input.',
38
+ required: false,
39
+ type: 'string'
40
+ },
41
+ {
42
+ name: 'description',
43
+ description: 'Helper text rendered below the input.\nHidden when `error` is set.',
44
+ required: false,
45
+ type: 'string'
46
+ },
47
+ {
48
+ name: 'error',
49
+ description: 'Error message rendered below the input. When set, the control receives\n`aria-invalid="true"` and `data-state="invalid"`. May be either a string\nor an `Error` object whose `messages?: string[]` is rendered as stacked\nlines (with `Error.message` as the fallback).',
50
+ required: false,
51
+ type: 'string | FrappeUIError'
52
+ },
53
+ {
54
+ name: 'required',
55
+ description: 'Marks the field as required. Renders an asterisk next to the label and\nforwards `required` / `aria-required` to the underlying control.',
56
+ required: false,
57
+ type: 'boolean'
58
+ },
59
+ {
60
+ name: 'id',
61
+ description: 'HTML id of the underlying control. Auto-generated via `useId()` if omitted.',
62
+ required: false,
63
+ type: 'string'
64
+ },
65
+ {
66
+ name: 'modelValue',
67
+ description: '',
68
+ required: false,
69
+ type: 'string | null',
70
+ default: 'null'
71
+ },
72
+ {
73
+ name: 'open',
74
+ description: '',
75
+ required: false,
76
+ type: 'boolean',
77
+ default: 'false'
78
+ }
79
+ ]
80
+
81
+ const slotsData = [
82
+ {
83
+ name: 'suffix',
84
+ description: '',
85
+ type: '{ open: boolean; disabled: boolean; query: string; selectedOption: ComboboxSelectableOption | null; '
86
+ }
87
+ ]
88
+
89
+ const emitsData = [
90
+ {
91
+ name: 'update:modelValue',
92
+ description: 'Fired when the model value changes.',
93
+ type: 'unknown[]'
94
+ },
95
+ {
96
+ name: 'update:open',
97
+ description: 'Fired when the open state changes.',
98
+ type: 'unknown[]'
99
+ },
100
+ {
101
+ name: 'create',
102
+ description: '',
103
+ type: '[query: string]'
104
+ }
105
+ ]
106
+ </script>
107
+ ## API Reference
108
+
109
+ <PropsTable name="Link" :data="propsData"/>
110
+
111
+ <SlotsTable :data="slotsData"/>
112
+
113
+ <EmitsTable :data="emitsData"/>
114
+
@@ -0,0 +1,215 @@
1
+ import { defineComponent, h, ref } from 'vue'
2
+ import Link from './Link.vue'
3
+
4
+ const users = [
5
+ { value: 'alice', label: 'Alice' },
6
+ { value: 'bob', label: 'Bob' },
7
+ { value: 'carol', label: 'Carol' },
8
+ ]
9
+
10
+ function stubSearchLink(rows = users) {
11
+ cy.intercept('POST', '/api/method/frappe.desk.search.search_link', {
12
+ statusCode: 200,
13
+ body: { message: rows },
14
+ }).as('searchLink')
15
+ }
16
+
17
+ describe('Link', () => {
18
+ beforeEach(() => {
19
+ stubSearchLink()
20
+ })
21
+
22
+ describe('rendering', () => {
23
+ it('emits data-slot="link" on the root wrapper', () => {
24
+ cy.mount(Link, { props: { doctype: 'User' } })
25
+ cy.get('[data-slot="link"]').should('exist')
26
+ })
27
+
28
+ it('forwards id/label/description/required to the underlying Combobox (P5)', () => {
29
+ cy.mount(Link, {
30
+ props: {
31
+ doctype: 'User',
32
+ id: 'user-picker',
33
+ label: 'User',
34
+ description: 'Pick a user',
35
+ required: true,
36
+ },
37
+ })
38
+
39
+ cy.get('label[for="user-picker"]').should('contain.text', 'User')
40
+ cy.get('[role="combobox"]#user-picker').should(
41
+ 'have.attr',
42
+ 'aria-required',
43
+ 'true',
44
+ )
45
+ cy.contains('Pick a user').should('exist')
46
+ })
47
+
48
+ it('forwards `error` and wires aria-invalid', () => {
49
+ cy.mount(Link, {
50
+ props: {
51
+ doctype: 'User',
52
+ id: 'user-picker',
53
+ error: 'Boom',
54
+ },
55
+ })
56
+ cy.get('[role="combobox"]#user-picker').should(
57
+ 'have.attr',
58
+ 'aria-invalid',
59
+ 'true',
60
+ )
61
+ cy.contains('Boom').should('exist')
62
+ })
63
+ })
64
+
65
+ describe('v-model', () => {
66
+ it('round-trips a selection', () => {
67
+ cy.mount(Link, {
68
+ props: {
69
+ doctype: 'User',
70
+ 'onUpdate:modelValue': cy.spy().as('onUpdate'),
71
+ },
72
+ })
73
+
74
+ cy.wait('@searchLink')
75
+ cy.get('[role="combobox"]').click()
76
+ cy.contains('[role="option"]', 'Alice').click()
77
+ cy.get('@onUpdate').should('have.been.calledWith', 'alice')
78
+ })
79
+
80
+ it('clear sets the model to null (never the empty string)', () => {
81
+ const Host = defineComponent({
82
+ setup() {
83
+ const value = ref<string | null>('alice')
84
+ return { value }
85
+ },
86
+ render() {
87
+ return h(Link, {
88
+ doctype: 'User',
89
+ modelValue: this.value,
90
+ 'onUpdate:modelValue': (v: string | null) => (this.value = v),
91
+ })
92
+ },
93
+ })
94
+
95
+ cy.mount(Host).then(({ component }) => {
96
+ const host = component as unknown as { value: string | null }
97
+ cy.get('[data-slot="clear"]').click({ force: true })
98
+ cy.then(() => expect(host.value).to.equal(null))
99
+ })
100
+ })
101
+ })
102
+
103
+ describe('default clear', () => {
104
+ it('renders a clear button when value is set and not required', () => {
105
+ cy.mount(Link, {
106
+ props: {
107
+ doctype: 'User',
108
+ modelValue: 'alice',
109
+ 'onUpdate:modelValue': cy.spy().as('onUpdate'),
110
+ },
111
+ })
112
+ cy.get('[data-slot="clear"]').should('exist').click({ force: true })
113
+ cy.get('@onUpdate').should('have.been.calledWith', null)
114
+ })
115
+
116
+ it('suppresses the clear button when required is true', () => {
117
+ cy.mount(Link, {
118
+ props: { doctype: 'User', modelValue: 'alice', required: true },
119
+ })
120
+ cy.get('[data-slot="clear"]').should('not.exist')
121
+ })
122
+
123
+ it('suppresses the clear button when modelValue is empty', () => {
124
+ cy.mount(Link, { props: { doctype: 'User' } })
125
+ cy.get('[data-slot="clear"]').should('not.exist')
126
+ })
127
+ })
128
+
129
+ describe('creatable', () => {
130
+ it('does not render the Create row by default', () => {
131
+ cy.mount(Link, { props: { doctype: 'User' } })
132
+ cy.get('[role="combobox"]').click().type('zed')
133
+ cy.contains('[role="option"]', 'Create').should('not.exist')
134
+ })
135
+
136
+ it('shows the Create row only when query is non-empty', () => {
137
+ cy.mount(Link, { props: { doctype: 'User', creatable: true } })
138
+ cy.get('[role="combobox"]').click()
139
+ cy.contains('[role="option"]', 'Create').should('not.exist')
140
+ cy.get('[role="combobox"]').type('zed')
141
+ cy.contains('[role="option"]', 'Create').should('contain.text', 'zed')
142
+ })
143
+
144
+ it('emits @create with just the query (one argument)', () => {
145
+ cy.mount(Link, {
146
+ props: {
147
+ doctype: 'User',
148
+ creatable: true,
149
+ onCreate: cy.spy().as('onCreate'),
150
+ },
151
+ })
152
+ cy.get('[role="combobox"]').click().type('zed')
153
+ cy.contains('[role="option"]', 'Create').click()
154
+ cy.get('@onCreate').should('have.been.calledOnce')
155
+ cy.get('@onCreate').should('have.been.calledWith', 'zed')
156
+ cy.get('@onCreate').then((spy) => {
157
+ // Spec: @create is one-arg. Guard against the old (query, close) shape.
158
+ const callArgs = (spy as unknown as sinon.SinonSpy).getCall(0).args
159
+ expect(callArgs).to.have.length(1)
160
+ })
161
+ })
162
+ })
163
+
164
+ describe('v-model:open', () => {
165
+ it('emits update:open when the popover toggles', () => {
166
+ cy.mount(Link, {
167
+ props: {
168
+ doctype: 'User',
169
+ 'onUpdate:open': cy.spy().as('onOpen'),
170
+ },
171
+ })
172
+ cy.get('[role="combobox"]').click()
173
+ cy.get('@onOpen').should('have.been.calledWith', true)
174
+ })
175
+ })
176
+
177
+ describe('exposed API', () => {
178
+ it('exposes a zero-arg reload() that re-fetches options', () => {
179
+ const Host = defineComponent({
180
+ setup() {
181
+ const link = ref<{ reload: () => void } | null>(null)
182
+ return { link }
183
+ },
184
+ render() {
185
+ return h(Link, { doctype: 'User', ref: 'link' })
186
+ },
187
+ })
188
+
189
+ cy.mount(Host).then(({ component }) => {
190
+ cy.wait('@searchLink')
191
+ cy.then(() => {
192
+ const exposed = (component as unknown as { link: { reload: () => void } }).link
193
+ expect(exposed.reload).to.be.a('function')
194
+ expect(exposed.reload.length).to.equal(0)
195
+ exposed.reload()
196
+ })
197
+ cy.wait('@searchLink')
198
+ })
199
+ })
200
+ })
201
+
202
+ describe('#suffix slot', () => {
203
+ it('replaces the default clear button', () => {
204
+ cy.mount(Link, {
205
+ props: { doctype: 'User', modelValue: 'alice' },
206
+ slots: {
207
+ suffix: () =>
208
+ h('button', { 'data-slot': 'redirect', type: 'button' }, 'Go'),
209
+ },
210
+ })
211
+ cy.get('[data-slot="clear"]').should('not.exist')
212
+ cy.get('[data-slot="redirect"]').should('exist')
213
+ })
214
+ })
215
+ })
@@ -0,0 +1,38 @@
1
+ # Link
2
+
3
+ A single-record picker for a Frappe doctype. Composes `Combobox` and fetches its options directly from `frappe.desk.search.search_link` — pick a doctype, and the rest works.
4
+
5
+ ## Simple
6
+ A bare picker. `v-model` carries the selected record's primary key (a string), or `null` when nothing is selected.
7
+
8
+ <ComponentPreview csr="true" name="Link-Simple" layout="stacked" />
9
+
10
+ ## Labeling, Description, Error, Required
11
+ `Link` accepts the standard input labeling props — `label`, `description`, `error`, `required` — and forwards them to the underlying `Combobox`, so ARIA wiring (`aria-required`, `aria-invalid`, `aria-errormessage`) and the InputLabel chrome render identically to a standalone `Combobox`.
12
+
13
+ <ComponentPreview csr="true" name="Link-Labeling" />
14
+
15
+ ## Filters
16
+ The `filters` prop is a `Record<string, unknown>` passed straight through to `search_link`. Frappe's underlying endpoint also accepts list-form and SQL-string filters, but the component boundary intentionally narrows to the dict form — it covers every CRM call site without leaking backend serialization into the public API. Reactive — changing `filters` triggers a refetch. The `#footer` slot is the canonical home for a filter-status row (or any non-selectable popover affordance) and renders below both the options list and the `creatable` create-new row.
17
+
18
+ <ComponentPreview csr="true" name="Link-Filters" />
19
+
20
+ ## Creatable
21
+ `creatable: true` injects a "Create" row at the bottom of the popover, visible only when the user has typed. Clicking it emits `@create` with the typed query — the consumer owns the create flow (typically a dialog) and assigns the freshly-created record back to `v-model` on success. To replace the default create row markup (icon, helper text, copy), supply the `#item-create` slot — scope is `{ query }`.
22
+
23
+ <ComponentPreview csr="true" name="Link-Creatable" />
24
+
25
+ ## Suffix
26
+ By default, Link renders a clear button in the suffix slot when `modelValue` is non-null and `required: false`. To replace it with your own affordance — an "Open record" link, an "Edit" button — supply `#suffix`. The slot fully takes over; if you still want a clear button alongside your action, render one yourself. To dismiss the options dropdown when your action takes over (e.g. opens a dialog), bind `v-model:open` and set it to `false`.
27
+
28
+ <ComponentPreview csr="true" name="Link-Suffix" />
29
+
30
+ ## Member Picker
31
+ `Link` forwards every `Combobox` per-row slot — `#item-prefix`, `#item-label`, `#item-suffix`, `#item` — so a doctype picker can render an avatar, role, or any other contextual chrome without dropping down to `<Combobox>` directly.
32
+
33
+ <ComponentPreview csr="true" name="Link-MemberPicker" />
34
+
35
+ ## Combobox passthrough
36
+ Link composes `Combobox` and forwards unrecognized props via `$attrs` and all non-overridden slots. Anything in the [`Combobox` API](../../docs/components/combobox) that Link doesn't claim itself (`options`, `loading`, and the `#suffix` / `#item-create` slots which have Link-specific defaults) reaches the underlying component unchanged.
37
+
38
+ <!-- @include: ./Link.api.md -->
@@ -1,52 +1,99 @@
1
1
  <template>
2
- <div class="flex flex-col gap-1.5" :class="attrs.class" :style="attrs.style">
3
- <FormLabel v-if="label" :label="label" size="sm" :required="required" />
2
+ <div data-slot="link" class="contents">
4
3
  <Combobox
4
+ ref="comboboxRef"
5
+ v-bind="$attrs"
5
6
  v-model="model"
6
- :placeholder="placeholder || `Select ${doctype}`"
7
+ v-model:open="open"
8
+ class="group !gap-1"
9
+ :label="label"
10
+ :description="description"
11
+ :error="error"
12
+ :required="required"
13
+ :id="id"
7
14
  :options="linkOptions"
8
- @input="handleInputChange"
15
+ :disabled="disabled"
16
+ :placeholder="placeholder ?? `Search ${doctype.toLowerCase()}`"
17
+ :loading="options.loading && !options.data"
18
+ @update:query="handleInputChange"
9
19
  @focus="() => loadOptions('')"
10
- :open-on-focus="true"
11
- v-bind="attrsWithoutClassStyle"
12
- :variant="props.variant"
13
20
  >
14
- <template #create-new="{ searchTerm }">
15
- <LucidePlus class="size-4 mr-2" />
16
- <span class="font-medium"> Create new {{ doctype }}</span>
21
+ <template
22
+ v-for="(_, name) in forwardedSlots"
23
+ #[name]="slotProps"
24
+ :key="name"
25
+ >
26
+ <slot :name="name" v-bind="slotProps" />
27
+ </template>
28
+
29
+ <template v-if="slots.suffix" #suffix="suffixProps">
30
+ <slot name="suffix" v-bind="suffixProps" />
31
+ </template>
32
+ <template v-else-if="showClear" #suffix>
33
+ <button
34
+ type="button"
35
+ aria-label="Clear"
36
+ data-slot="clear"
37
+ class="group-hover:grid group-focus:grid group-focus-within:grid hidden size-4 place-items-center rounded-sm text-ink-gray-5 hover:bg-surface-gray-3 hover:text-ink-gray-7 focus:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
38
+ @click="clearValue"
39
+ @pointerdown.stop
40
+ >
41
+ <span class="lucide-x size-3.5" />
42
+ </button>
43
+ </template>
44
+
45
+ <template v-if="slots['item-create']" #item-create="slotProps">
46
+ <slot name="item-create" v-bind="slotProps" />
47
+ </template>
48
+ <template v-else #item-create="{ query }">
49
+ <div class="flex">
50
+ <span class="truncate">
51
+ Create
52
+ <span v-if="query" class="font-medium text-ink-gray-8">
53
+ {{ query }}
54
+ </span>
55
+ </span>
56
+ </div>
17
57
  </template>
18
58
  </Combobox>
19
59
  </div>
20
60
  </template>
21
61
 
22
62
  <script setup lang="ts">
23
- import { watch, useAttrs, computed } from 'vue'
24
- import { Combobox, type ComboboxOption } from '../../src/components/Combobox'
25
- import FormLabel from '../../src/components/FormLabel.vue'
63
+ import { computed, ref, useSlots, watch } from 'vue'
64
+ import { Combobox } from '../../src/components/Combobox'
65
+ import type {
66
+ ComboboxCustomOption,
67
+ ComboboxOption,
68
+ } from '../../src/components/Combobox/types'
26
69
  import debounce from '../../src/utils/debounce'
27
- // @ts-ignore - Vue SFC without explicit types
28
70
  import { createResource } from '../../src/resources'
29
71
  import { frappeRequest } from '../../src/utils/frappeRequest'
30
- import type { LinkProps, SelectOption } from './types'
31
- import LucidePlus from '~icons/lucide/plus'
72
+ import type { LinkEmits, LinkExposed, LinkOption, LinkProps } from './types'
32
73
 
33
74
  const props = withDefaults(defineProps<LinkProps>(), {
34
- label: '',
35
75
  filters: () => ({}),
36
- variant: 'subtle',
76
+ creatable: false,
77
+ disabled: false,
37
78
  })
38
- const model = defineModel<string | null>({ default: '' })
39
- const emit = defineEmits<{
40
- (e: 'create', searchTerm: string): void
41
- }>()
79
+
80
+ const model = defineModel<string | null>({ default: null })
81
+ const open = defineModel<boolean>('open', { default: false })
82
+ const comboboxRef = ref<{ focus: () => void } | null>(null)
83
+
84
+ const emit = defineEmits<LinkEmits>()
85
+
42
86
  defineOptions({ inheritAttrs: false })
43
87
 
44
- const attrs = useAttrs() as Record<string, any>
45
- const attrsWithoutClassStyle = computed(() => {
46
- return Object.fromEntries(
47
- Object.entries(attrs).filter(([key]) => key !== 'class' && key !== 'style'),
48
- )
49
- })
88
+ const slots = useSlots()
89
+
90
+ const forwardedSlots = computed(() =>
91
+ Object.fromEntries(
92
+ Object.entries(slots).filter(
93
+ ([name]) => name !== 'suffix' && name !== 'item-create',
94
+ ),
95
+ ),
96
+ )
50
97
 
51
98
  const options = createResource({
52
99
  url: 'frappe.desk.search.search_link',
@@ -57,31 +104,35 @@ const options = createResource({
57
104
  },
58
105
  method: 'POST',
59
106
  resourceFetcher: frappeRequest,
60
- transform: (data: SelectOption[]) => {
61
- return data.map((doc) => ({
107
+ transform: (data: LinkOption[]): LinkOption[] =>
108
+ data.map((doc: any) => ({
62
109
  label: doc.label || doc.value,
63
110
  value: doc.value,
64
- }))
65
- },
111
+ description: doc.description,
112
+ })),
66
113
  })
67
114
 
68
- const createNewOption = {
69
- type: 'custom' as const,
70
- key: 'create_new',
115
+ const createNewOption: ComboboxCustomOption = {
116
+ type: 'custom',
117
+ key: 'create',
71
118
  label: 'Create New',
72
- slotName: 'create-new',
73
- condition: () => true,
119
+ slot: 'create',
120
+ condition: ({ query }: { query: string }) => Boolean(query.trim()),
74
121
  onClick: ({ query }) => emit('create', query),
75
- } as ComboboxOption
122
+ }
76
123
 
77
- const linkOptions = computed(() => {
124
+ const linkOptions = computed<ComboboxOption[]>(() => {
78
125
  const _options = options.data || []
79
- if (props.allowCreate) {
126
+ if (props.creatable) {
80
127
  return [..._options, createNewOption]
81
128
  }
82
129
  return _options
83
130
  })
84
131
 
132
+ const showClear = computed(
133
+ () => !props.disabled && !!model.value && !props.required,
134
+ )
135
+
85
136
  const loadOptions = (txt: string = '') => {
86
137
  options.update({
87
138
  params: {
@@ -93,12 +144,20 @@ const loadOptions = (txt: string = '') => {
93
144
  options.reload()
94
145
  }
95
146
 
96
- const handleInputChange = debounce((inputString: string) => {
97
- loadOptions(inputString || '')
147
+ const handleInputChange = debounce((value: string) => {
148
+ loadOptions(value || '')
98
149
  }, 300)
99
150
 
151
+ const clearValue = () => {
152
+ model.value = null
153
+ open.value = false
154
+ comboboxRef.value?.focus()
155
+ }
156
+
100
157
  watch([() => props.doctype, () => props.filters], () => loadOptions(''), {
101
158
  immediate: true,
102
159
  deep: true,
103
160
  })
161
+
162
+ defineExpose<LinkExposed>({ reload: () => loadOptions('') })
104
163
  </script>
@@ -1,2 +1,2 @@
1
1
  export { default as Link } from './Link.vue'
2
- export type { LinkProps } from './types'
2
+ export type { LinkEmits, LinkExposed, LinkOption, LinkProps } from './types'
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { Link } from 'frappe-ui/frappe'
4
+ import { useMockSearchLink, MOCK_LOST_REASONS } from './_mock'
5
+
6
+ // Local copy of the seed list so created rows don't leak into the sibling
7
+ // Labeling story on the same docs page. Both variations share this list —
8
+ // pushing through either Link makes the new option visible in the other.
9
+ const reasons = [...MOCK_LOST_REASONS]
10
+ useMockSearchLink({ 'CRM Lost Reason': reasons })
11
+
12
+ const reasonA = ref<string | null>(null)
13
+ const openA = ref(false)
14
+ const lastCreateA = ref<string | null>(null)
15
+
16
+ function handleCreateA(query: string) {
17
+ // Real apps open a create dialog and persist the record; on success they
18
+ // assign the new primary key back to v-model. Here we mimic that flow:
19
+ // append to the mock dataset, select it, and close the popover.
20
+ if (!reasons.some((r) => r.value === query)) reasons.push({ value: query })
21
+ reasonA.value = query
22
+ openA.value = false
23
+ lastCreateA.value = query
24
+ }
25
+
26
+ const reasonB = ref<string | null>(null)
27
+ const openB = ref(false)
28
+
29
+ function handleCreateB(query: string) {
30
+ if (!reasons.some((r) => r.value === query)) reasons.push({ value: query })
31
+ reasonB.value = query
32
+ openB.value = false
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <div class="w-full !py-20 grid place-items-center">
38
+ <div class="grid w-96 gap-8">
39
+ <div class="grid gap-3">
40
+ <Link
41
+ v-model="reasonA"
42
+ v-model:open="openA"
43
+ doctype="CRM Lost Reason"
44
+ label="Lost reason"
45
+ placeholder="Select or create a reason"
46
+ creatable
47
+ @create="handleCreateA"
48
+ />
49
+
50
+ <div class="text-sm text-ink-gray-5">
51
+ Selected: <code class="text-ink-gray-7">{{ reasonA || 'None' }}</code>
52
+ </div>
53
+ <div v-if="lastCreateA" class="text-sm text-ink-gray-5">
54
+ Last <code>@create</code> query:
55
+ <code class="text-ink-gray-7">{{ lastCreateA }}</code>
56
+ </div>
57
+ </div>
58
+
59
+ <div class="grid gap-3">
60
+ <div class="text-base text-ink-gray-5">Customised create row</div>
61
+ <Link
62
+ v-model="reasonB"
63
+ v-model:open="openB"
64
+ doctype="CRM Lost Reason"
65
+ label="Lost reason"
66
+ placeholder="Type a new reason…"
67
+ creatable
68
+ @create="handleCreateB"
69
+ >
70
+ <template #item-create="{ query }">
71
+ <div class="flex items-center gap-2 min-w-0">
72
+ <span
73
+ class="lucide-plus size-3.5 text-ink-gray-5 shrink-0"
74
+ />
75
+ <span class="truncate">
76
+ Add new reason
77
+ <span
78
+ v-if="query"
79
+ class="font-medium text-ink-gray-8 italic"
80
+ >
81
+ “{{ query }}”
82
+ </span>
83
+ </span>
84
+ </div>
85
+ </template>
86
+ </Link>
87
+
88
+ <p class="text-p-xs text-ink-gray-5">
89
+ Use <code>#item-create</code> to override the default create row —
90
+ add an icon, helper text, or any markup. Slot scope is
91
+ <code>{ query }</code>.
92
+ </p>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </template>