frappe-ui 1.0.0-beta.8 → 1.0.0-beta.9

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 (46) hide show
  1. package/package.json +16 -1
  2. package/src/components/CodeEditor/CodeEditor.api.md +140 -0
  3. package/src/components/CodeEditor/CodeEditor.cy.ts +156 -0
  4. package/src/components/CodeEditor/CodeEditor.md +104 -0
  5. package/src/components/CodeEditor/CodeEditor.vue +514 -0
  6. package/src/components/CodeEditor/CodePreview.test.ts +49 -0
  7. package/src/components/CodeEditor/CodePreview.vue +45 -0
  8. package/src/components/CodeEditor/index.ts +17 -0
  9. package/src/components/CodeEditor/languages.ts +63 -0
  10. package/src/components/CodeEditor/stories/Default.vue +16 -0
  11. package/src/components/CodeEditor/stories/Labeling.vue +25 -0
  12. package/src/components/CodeEditor/stories/MaxHeight.vue +24 -0
  13. package/src/components/CodeEditor/stories/Preview.vue +36 -0
  14. package/src/components/CodeEditor/stories/Sizes.vue +15 -0
  15. package/src/components/CodeEditor/stories/Variants.vue +13 -0
  16. package/src/components/CodeEditor/theme.ts +218 -0
  17. package/src/components/CodeEditor/types.ts +69 -0
  18. package/src/components/Combobox/Combobox.api.md +21 -21
  19. package/src/components/Combobox/Combobox.cy.ts +54 -14
  20. package/src/components/Combobox/Combobox.vue +230 -241
  21. package/src/components/Combobox/stories/Footer.vue +10 -2
  22. package/src/components/Combobox/types.ts +8 -29
  23. package/src/components/Duration/Duration.api.md +92 -0
  24. package/src/components/Duration/Duration.cy.ts +183 -0
  25. package/src/components/Duration/Duration.md +98 -0
  26. package/src/components/Duration/Duration.vue +114 -0
  27. package/src/components/Duration/duration.ts +185 -0
  28. package/src/components/Duration/index.ts +8 -0
  29. package/src/components/Duration/stories/Default.vue +10 -0
  30. package/src/components/Duration/stories/Formats.vue +16 -0
  31. package/src/components/Duration/stories/RealWorld.vue +50 -0
  32. package/src/components/Duration/stories/Sizes.vue +15 -0
  33. package/src/components/Duration/stories/States.vue +21 -0
  34. package/src/components/Duration/types.ts +52 -0
  35. package/src/components/MultiSelect/MultiSelect.api.md +9 -9
  36. package/src/components/MultiSelect/MultiSelect.md +38 -10
  37. package/src/components/MultiSelect/MultiSelect.vue +199 -226
  38. package/src/components/MultiSelect/stories/TagsTrigger.vue +2 -2
  39. package/src/components/MultiSelect/types.ts +3 -3
  40. package/src/components/TimePicker/TimePicker.api.md +59 -13
  41. package/src/components/TimePicker/TimePicker.md +6 -0
  42. package/src/components/TimePicker/TimePicker.vue +16 -0
  43. package/src/components/TimePicker/stories/Labeling.vue +21 -0
  44. package/src/components/TimePicker/stories/SizesAndVariants.vue +23 -0
  45. package/src/components/TimePicker/types.ts +7 -1
  46. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.8",
3
+ "version": "1.0.0-beta.9",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -56,6 +56,10 @@
56
56
  "import": "./src/molecules/editor/index.ts",
57
57
  "types": "./src/molecules/editor/index.ts"
58
58
  },
59
+ "./code-editor": {
60
+ "import": "./src/components/CodeEditor/index.ts",
61
+ "types": "./src/components/CodeEditor/index.ts"
62
+ },
59
63
  "./drive": {
60
64
  "import": "./frappe/drive/index.js"
61
65
  },
@@ -104,6 +108,16 @@
104
108
  "author": "Frappe Technologies Pvt. Ltd.",
105
109
  "license": "MIT",
106
110
  "dependencies": {
111
+ "@codemirror/lang-css": "^6.3.0",
112
+ "@codemirror/lang-html": "^6.4.9",
113
+ "@codemirror/lang-javascript": "^6.2.2",
114
+ "@codemirror/lang-json": "^6.0.1",
115
+ "@codemirror/lang-markdown": "^6.3.1",
116
+ "@codemirror/lang-python": "^6.1.6",
117
+ "@codemirror/lang-sass": "^6.0.2",
118
+ "@codemirror/lang-sql": "^6.8.0",
119
+ "@codemirror/lang-xml": "^6.1.0",
120
+ "@codemirror/lang-yaml": "^6.1.3",
107
121
  "@floating-ui/dom": "^1.7.4",
108
122
  "@headlessui/vue": "^1.7.14",
109
123
  "@popperjs/core": "^2.11.2",
@@ -146,6 +160,7 @@
146
160
  "@tiptap/suggestion": "^3.26.0",
147
161
  "@tiptap/vue-3": "^3.26.0",
148
162
  "@vueuse/core": "^10.4.1",
163
+ "codemirror": "^6.0.1",
149
164
  "dayjs": "^1.11.13",
150
165
  "dompurify": "^3.4.0",
151
166
  "echarts": "^5.6.0",
@@ -0,0 +1,140 @@
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 codeEditorProps = [
8
+ {
9
+ name: 'modelValue',
10
+ description: 'The editor\'s text content (controlled, two-way via `v-model`).',
11
+ required: true,
12
+ type: 'string'
13
+ },
14
+ {
15
+ name: 'language',
16
+ description: 'CodeMirror language key; falls through to plain text when unset/unknown.',
17
+ required: false,
18
+ type: 'CodeLanguage | (string & {})',
19
+ default: '"plain"'
20
+ },
21
+ {
22
+ name: 'disabled',
23
+ description: 'If true, the editor is disabled: greyed out and not editable.',
24
+ required: false,
25
+ type: 'boolean',
26
+ default: 'false'
27
+ },
28
+ {
29
+ name: 'placeholder',
30
+ description: 'Placeholder shown when the editor is empty.',
31
+ required: false,
32
+ type: 'string',
33
+ default: '""'
34
+ },
35
+ {
36
+ name: 'variant',
37
+ description: 'Surface style; mirrors frappe-ui inputs. Defaults to `subtle`.',
38
+ required: false,
39
+ type: "Exclude<InputVariant, 'ghost'>",
40
+ default: '"subtle"'
41
+ },
42
+ {
43
+ name: 'size',
44
+ description: 'Size token, mirroring frappe-ui inputs. Scales the editor\'s font size and\nminimum height (`sm | md | lg | xl`). Defaults to `md`.',
45
+ required: false,
46
+ type: 'InputSize',
47
+ default: '"md"'
48
+ },
49
+ {
50
+ name: 'label',
51
+ description: 'Label rendered above (or beside, for binary controls) the input.',
52
+ required: false,
53
+ type: 'string'
54
+ },
55
+ {
56
+ name: 'description',
57
+ description: 'Helper text rendered below the input.\nHidden when `error` is set.',
58
+ required: false,
59
+ type: 'string'
60
+ },
61
+ {
62
+ name: 'error',
63
+ 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).',
64
+ required: false,
65
+ type: 'string | FrappeUIError'
66
+ },
67
+ {
68
+ name: 'required',
69
+ description: 'Marks the field as required. Renders an asterisk next to the label and\nforwards `required` / `aria-required` to the underlying control.',
70
+ required: false,
71
+ type: 'boolean'
72
+ },
73
+ {
74
+ name: 'id',
75
+ description: 'HTML id of the underlying control. Auto-generated via `useId()` if omitted.',
76
+ required: false,
77
+ type: 'string'
78
+ }
79
+ ]
80
+
81
+ const codeEditorSlots = [
82
+ {
83
+ name: 'label',
84
+ description: 'Overrides the rendered label content. Receives `{ required }`.',
85
+ type: '{ required: boolean; }'
86
+ },
87
+ {
88
+ name: 'description',
89
+ description: 'Overrides the rendered description content.',
90
+ type: 'any'
91
+ }
92
+ ]
93
+
94
+ const codeEditorEmits = [
95
+ {
96
+ name: 'update:modelValue',
97
+ description: 'Fired when the model value changes.',
98
+ type: '[value: string]'
99
+ },
100
+ {
101
+ name: 'change',
102
+ description: 'Fired after the value is committed.',
103
+ type: '[value: string]'
104
+ },
105
+ {
106
+ name: 'overflow',
107
+ description: 'Fired when content crosses the `--cm-max-height` cap (transitions only). The cap is a CSS hook, not a prop — set `--cm-max-height` on the root.',
108
+ type: '[overflowing: boolean]'
109
+ }
110
+ ]
111
+
112
+ const codePreviewProps = [
113
+ {
114
+ name: 'modelValue',
115
+ description: 'The source text to render as sanitized preview output.',
116
+ required: true,
117
+ type: 'string'
118
+ },
119
+ {
120
+ name: 'language',
121
+ description: 'Only `markdown` / `html` render a preview; anything else renders nothing.',
122
+ required: false,
123
+ type: 'CodeLanguage | (string & {})'
124
+ }
125
+ ]
126
+ </script>
127
+ ## API Reference
128
+
129
+ ### CodeEditor
130
+
131
+ <PropsTable name="CodeEditor" :data="codeEditorProps"/>
132
+
133
+ <SlotsTable :data="codeEditorSlots"/>
134
+
135
+ <EmitsTable :data="codeEditorEmits"/>
136
+
137
+ ### CodePreview
138
+
139
+ <PropsTable folder="CodeEditor" name="CodePreview" :data="codePreviewProps"/>
140
+
@@ -0,0 +1,156 @@
1
+ import CodeEditor from './CodeEditor.vue'
2
+
3
+ // CodeMirror lazy-loads in `onMounted` (dynamic imports) and renders into a
4
+ // `.cm-editor` with a contenteditable `.cm-content`. Cypress retries `cy.get`
5
+ // until that async mount settles, so no explicit wait is needed.
6
+
7
+ describe('CodeEditor', () => {
8
+ it('mounts the editor with the initial document', () => {
9
+ cy.mount(CodeEditor, { props: { modelValue: 'const a = 1' } })
10
+ cy.get('.cm-editor').should('exist')
11
+ cy.get('.cm-content').should('contain.text', 'const a = 1')
12
+ })
13
+
14
+ it('emits update:modelValue live while typing', () => {
15
+ cy.mount(CodeEditor, {
16
+ props: {
17
+ modelValue: '',
18
+ 'onUpdate:modelValue': cy.stub().as('update'),
19
+ },
20
+ })
21
+ cy.get('.cm-content').type('hello')
22
+ cy.get('@update').should('have.been.called')
23
+ // The last emit carries the full document.
24
+ cy.get('@update')
25
+ .its('lastCall.args.0')
26
+ .should('eq', 'hello')
27
+ })
28
+
29
+ it('emits change only on blur (commit), not on every keystroke', () => {
30
+ cy.mount(CodeEditor, {
31
+ props: {
32
+ modelValue: '',
33
+ onChange: cy.stub().as('change'),
34
+ },
35
+ })
36
+ cy.get('.cm-content').type('draft')
37
+ // No commit while focused.
38
+ cy.get('@change').should('not.have.been.called')
39
+ cy.get('.cm-content').blur()
40
+ cy.get('@change').should('have.been.calledOnceWith', 'draft')
41
+ })
42
+
43
+ it('Escape blurs the editor so the keyboard trap has an exit (commits)', () => {
44
+ cy.mount(CodeEditor, {
45
+ props: { modelValue: '', onChange: cy.stub().as('change') },
46
+ })
47
+ cy.get('.cm-content').type('x')
48
+ cy.get('.cm-content').trigger('keydown', { key: 'Escape' })
49
+ cy.get('@change').should('have.been.calledOnceWith', 'x')
50
+ })
51
+
52
+ it('does not emit update:modelValue when synced from the prop', () => {
53
+ // Updating modelValue externally must not echo back out as fresh input.
54
+ cy.mount(CodeEditor, {
55
+ props: {
56
+ modelValue: 'one',
57
+ 'onUpdate:modelValue': cy.stub().as('update'),
58
+ },
59
+ }).then(({ wrapper }) => {
60
+ wrapper.setProps({ modelValue: 'one two three' })
61
+ cy.get('.cm-content').should('contain.text', 'one two three')
62
+ cy.get('@update').should('not.have.been.called')
63
+ })
64
+ })
65
+
66
+ it('is not editable when disabled', () => {
67
+ cy.mount(CodeEditor, {
68
+ props: { modelValue: 'frozen', disabled: true },
69
+ })
70
+ cy.get('.cm-content').should('have.attr', 'contenteditable', 'false')
71
+ cy.get('.code-editor').should('have.attr', 'data-disabled', 'true')
72
+ })
73
+
74
+ it('swaps disabled at runtime without recreating the view', () => {
75
+ cy.mount(CodeEditor, {
76
+ props: { modelValue: 'frozen', disabled: true },
77
+ }).then(({ wrapper }) => {
78
+ cy.get('.cm-content').should('have.attr', 'contenteditable', 'false')
79
+ wrapper.setProps({ disabled: false })
80
+ cy.get('.cm-content').should('have.attr', 'contenteditable', 'true')
81
+ })
82
+ })
83
+
84
+ it('exposes the data-* styling hooks on the control', () => {
85
+ cy.mount(CodeEditor, {
86
+ props: { modelValue: '', variant: 'outline', size: 'lg' },
87
+ })
88
+ cy.get('.code-editor').should('have.attr', 'data-slot', 'control')
89
+ cy.get('.code-editor').should('have.attr', 'data-variant', 'outline')
90
+ cy.get('.code-editor').should('have.attr', 'data-size', 'lg')
91
+ })
92
+
93
+ describe('labeling contract', () => {
94
+ it('renders the label and wires aria onto the contentDOM', () => {
95
+ cy.mount(CodeEditor, {
96
+ props: {
97
+ modelValue: '',
98
+ label: 'Config',
99
+ description: 'JSON config',
100
+ },
101
+ })
102
+ cy.contains('label', 'Config').should('exist')
103
+ cy.get('.cm-content').then(($el) => {
104
+ const describedBy = $el.attr('aria-describedby')
105
+ expect(describedBy).to.be.ok
106
+ cy.get(`#${describedBy}`).should('contain.text', 'JSON config')
107
+ })
108
+ })
109
+
110
+ it('marks the contentDOM invalid and shows the error', () => {
111
+ cy.mount(CodeEditor, {
112
+ props: { modelValue: '', error: 'Invalid JSON' },
113
+ })
114
+ cy.get('.cm-content').should('have.attr', 'aria-invalid', 'true')
115
+ cy.get('.code-editor').should('have.attr', 'data-state', 'invalid')
116
+ cy.contains('Invalid JSON').should('exist')
117
+ })
118
+
119
+ it('forwards required onto aria-required', () => {
120
+ cy.mount(CodeEditor, {
121
+ props: { modelValue: '', label: 'Body', required: true },
122
+ })
123
+ cy.get('.cm-content').should('have.attr', 'aria-required', 'true')
124
+ })
125
+ })
126
+
127
+ describe('overflow', () => {
128
+ // The cap is a CSS hook (`--cm-max-height` on the root), not a prop — set it
129
+ // via attribute fallthrough, the same way a consumer would.
130
+ const longDoc = Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n')
131
+
132
+ it('emits overflow=true when content exceeds the --cm-max-height cap', () => {
133
+ cy.mount(CodeEditor, {
134
+ props: {
135
+ modelValue: longDoc,
136
+ onOverflow: cy.stub().as('overflow'),
137
+ },
138
+ attrs: { style: '--cm-max-height: 6rem' },
139
+ })
140
+ cy.get('@overflow').should('have.been.calledWith', true)
141
+ })
142
+
143
+ it('does not emit overflow when content fits under the cap', () => {
144
+ cy.mount(CodeEditor, {
145
+ props: {
146
+ modelValue: 'short',
147
+ onOverflow: cy.stub().as('overflow'),
148
+ },
149
+ attrs: { style: '--cm-max-height: 20rem' },
150
+ })
151
+ // Give the ResizeObserver/mount a beat, then assert it stayed quiet.
152
+ cy.get('.cm-editor').should('exist')
153
+ cy.get('@overflow').should('not.have.been.calledWith', true)
154
+ })
155
+ })
156
+ })
@@ -0,0 +1,104 @@
1
+ # CodeEditor
2
+
3
+ A CodeMirror 6 code field with syntax highlighting, theming that follows the
4
+ app's light/dark scheme, and an optional sanitized preview. CodeMirror is
5
+ lazy-loaded, so apps that never render a code field pay no runtime cost.
6
+
7
+ CodeEditor ships from its own subpath so the CodeMirror dependency only loads
8
+ for apps that opt in:
9
+
10
+ ```js
11
+ import { CodeEditor, CodePreview } from 'frappe-ui/code-editor'
12
+ ```
13
+
14
+ ## Playground
15
+
16
+ <ClientOnly><CodeEditorBuilder /></ClientOnly>
17
+
18
+ <ComponentPreview name="CodeEditor-Default" layout="stacked" class="mt-4" />
19
+
20
+ ## Languages
21
+
22
+ Pass a `language` key to enable highlighting. Each language tree-shakes into
23
+ its own async chunk, loaded on demand. Supported keys: `json`, `html`,
24
+ `javascript`, `python`, `sql`, `markdown`, `css`, `scss`, `yaml`, `xml`, and
25
+ `plain` (the default — no highlighting). Unknown keys fall through to plain
26
+ text. `json` additionally wires an inline lint gutter so invalid JSON is
27
+ flagged at its position.
28
+
29
+ ## Variants
30
+
31
+ The surface mirrors frappe-ui's input convention so a code field sits flush
32
+ with the TextInput/Textarea fields around it. `subtle` is the filled default and
33
+ `outline` is a bordered box on white. The borderless `ghost` variant is
34
+ intentionally not offered — a code surface without a border reads as plain text
35
+ and loses the affordance that it's an editable field.
36
+
37
+ <ComponentPreview name="CodeEditor-Variants" />
38
+
39
+ ## Sizes
40
+
41
+ `size` mirrors the frappe-ui input sizes (`sm | md | lg | xl`, default `md`),
42
+ scaling the editor's font size and minimum height.
43
+
44
+ <ComponentPreview name="CodeEditor-Sizes" />
45
+
46
+ ## Max height
47
+
48
+ The height cap is a CSS hook, not a prop — set the `--cm-max-height` custom
49
+ property on the root to any CSS length and content past the cap scrolls
50
+ internally:
51
+
52
+ ```vue
53
+ <CodeEditor v-model="code" style="--cm-max-height: 12rem" />
54
+ ```
55
+
56
+ Pair it with the `overflow` emit (fired only when the content crosses the cap)
57
+ to drive an expand/collapse affordance — that's the one piece you can't measure
58
+ from CSS yourself.
59
+
60
+ <ComponentPreview name="CodeEditor-MaxHeight" />
61
+
62
+ ## Preview
63
+
64
+ `CodePreview` is a separate, display-only primitive that renders sanitized
65
+ output for the two languages with a meaningful preview — `markdown` (rendered
66
+ via `marked`) and `html`. Both are passed through DOMPurify before rendering;
67
+ any other language renders nothing. The writer and preview are independent, so
68
+ how you compose them — editor-only, a Write/Preview toggle, or a side-by-side
69
+ split — is up to the consumer.
70
+
71
+ <ComponentPreview name="CodeEditor-Preview" />
72
+
73
+ ## Labeling
74
+
75
+ CodeEditor implements the shared input labeling contract (`label`,
76
+ `description`, `error`, `required`). When something needs the chrome it renders
77
+ a wrapping field; otherwise the editor mounts bare, so the primitive's
78
+ footprint is unchanged. Setting `error` flips the field to its invalid state
79
+ (`data-state="invalid"`, `aria-invalid`) and renders the message below; the
80
+ ARIA wiring is applied to the editor's internal content element so it reaches
81
+ the focusable control.
82
+
83
+ <ComponentPreview name="CodeEditor-Labeling" />
84
+
85
+ ## Keyboard
86
+
87
+ | Keys | Action |
88
+ | --- | --- |
89
+ | `Tab` / `Shift`+`Tab` | Indent / dedent the current line or selection |
90
+ | `Escape` | Release focus so `Tab` moves to the next field |
91
+
92
+ `Tab` is rebound to indent (the editor default is a focus-move). `Escape`
93
+ blurs the editor so keyboard users can still tab on — the standard CodeMirror
94
+ keyboard-trap escape hatch (WCAG 2.1.2).
95
+
96
+ ## Commit contract
97
+
98
+ `update:modelValue` fires live on every change (mirrors the textarea field
99
+ contract); `change` fires on blur, the commit point where a wrapper would
100
+ normalize the value (e.g. JSON pretty-print). External `modelValue` changes
101
+ are diffed into the view so the caret, selection, and scroll position survive
102
+ a live transform.
103
+
104
+ <!-- @include: ./CodeEditor.api.md -->