Skip to content

Commit

Permalink
feat: add JSON control (#319)
Browse files Browse the repository at this point in the history
Co-authored-by: Guillaume Chau <guillaume.b.chau@gmail.com>

Related to #30
  • Loading branch information
hugoattal committed Oct 9, 2022
1 parent 79f2bd4 commit a9d9701
Show file tree
Hide file tree
Showing 15 changed files with 374 additions and 51 deletions.
5 changes: 5 additions & 0 deletions examples/vue3/src/components/Controls.story.vue
Expand Up @@ -19,6 +19,7 @@ function initState () {
longText: 'Longer text...',
select: 'crash-bandicoot',
radio: 'metal-gear',
object: { foo: 'bar' },
}
}
</script>
Expand Down Expand Up @@ -67,6 +68,10 @@ function initState () {
title="HstRadio"
:options="radioOptions"
/>
<HstJson
v-model="state.object"
title="HstJson"
/>
</template>
</Variant>
</Story>
Expand Down
@@ -1,13 +1,8 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { Icon } from '@iconify/vue'
import type { PropDefinition, AutoPropComponentDefinition } from '@histoire/shared'
import {
HstText,
HstNumber,
HstCheckbox,
HstTextarea,
} from '@histoire/controls'
import type { AutoPropComponentDefinition, PropDefinition } from '@histoire/shared'
import { HstCheckbox, HstJson, HstNumber, HstText } from '@histoire/controls'
import type { Variant } from '../../types'
const props = defineProps<{
Expand All @@ -26,35 +21,15 @@ const comp = computed(() => {
return HstCheckbox
case 'object':
default:
return HstTextarea
return HstJson
}
})
const isJSON = computed(() => comp.value === HstTextarea)
const invalidValue = ref('')
const model = computed({
get: () => {
if (invalidValue.value) {
return invalidValue.value
}
let val = props.variant.state._hPropState[props.component.index]?.[props.definition.name]
if (val && isJSON.value) {
val = JSON.stringify(val, null, 2)
}
return val
return props.variant.state._hPropState[props.component.index]?.[props.definition.name]
},
set: (value) => {
invalidValue.value = ''
if (isJSON.value) {
try {
value = JSON.parse(value)
} catch (e) {
invalidValue.value = value
return
}
}
if (!props.variant.state._hPropState[props.component.index]) {
// eslint-disable-next-line vue/no-mutating-props
props.variant.state._hPropState[props.component.index] = {}
Expand All @@ -80,16 +55,8 @@ const canReset = computed(() => props.variant.state?._hPropState?.[props.compone
v-if="comp"
v-model="model"
:title="`${definition.name}${canReset ? ' *' : ''}`"
:placeholder="isJSON ? 'Enter JSON' : null"
>
<template #actions>
<Icon
v-if="invalidValue"
v-tooltip="'JSON error'"
icon="carbon:warning-alt"
class="htw-text-orange-500"
/>

<Icon
v-tooltip="'Remove override'"
icon="carbon:erase"
Expand Down
17 changes: 17 additions & 0 deletions packages/histoire-app/src/app/util/dark.ts
@@ -1,4 +1,21 @@
import { watch } from 'vue'
import { useDark, useToggle } from '@vueuse/core'

export const isDark = useDark({ valueDark: 'htw-dark' })
export const toggleDark = useToggle(isDark)

function applyDarkToControls () {
window.__hst_controls_dark?.forEach(ref => {
ref.value = isDark.value
})
}

watch(isDark, () => {
applyDarkToControls()
}, {
immediate: true,
})

window.__hst_controls_dark_ready = () => {
applyDarkToControls()
}
9 changes: 9 additions & 0 deletions packages/histoire-app/src/shim.d.ts
@@ -1,7 +1,16 @@
/// <reference types="vite/client" />

import type { Ref } from '@histoire/vendors/vue'

declare module '*.vue' {
import { ComponentOptions } from 'vue'
const comp: ComponentOptions
export default comp
}

global {
interface Window {
__hst_controls_dark: Ref<boolean>[]
__hst_controls_dark_ready: () => void
}
}
7 changes: 7 additions & 0 deletions packages/histoire-controls/package.json
Expand Up @@ -40,6 +40,13 @@
"test": "peeky run"
},
"dependencies": {
"@codemirror/commands": "^6.1.1",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/language": "^6.2.1",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.1.2",
"@codemirror/theme-one-dark": "^6.1.0",
"@codemirror/view": "^6.3.0",
"@histoire/vendors": "^0.11.2"
},
"devDependencies": {
Expand Down
9 changes: 5 additions & 4 deletions packages/histoire-controls/src/components/HstWrapper.vue
Expand Up @@ -5,13 +5,14 @@ export default {
</script>

<script lang="ts" setup>
import { withDefaults, computed } from 'vue'
import { withDefaults } from 'vue'
import { VTooltip as vTooltip } from 'floating-vue'
const props = withDefaults(defineProps<{
withDefaults(defineProps<{
title?: string
tag?: string
}>(), {
title: undefined,
tag: 'label',
})
Expand All @@ -32,8 +33,8 @@ const props = withDefaults(defineProps<{
>
{{ title }}
</span>
<span class="htw-grow htw-flex htw-items-center htw-gap-1">
<span class="htw-block htw-grow">
<span class="htw-grow htw-max-w-full htw-flex htw-items-center htw-gap-1">
<span class="htw-block htw-grow htw-max-w-full">
<slot />
</span>
<slot name="actions" />
Expand Down
Expand Up @@ -9,8 +9,8 @@ exports[`HstCheckbox toggle to checked 1`] = `
<span class="htw-w-28 htw-whitespace-nowrap htw-text-ellipsis htw-overflow-hidden htw-shrink-0 v-popper--has-tooltip">
Label
</span>
<span class="htw-grow htw-flex htw-items-center htw-gap-1">
<span class="htw-block htw-grow">
<span class="htw-grow htw-max-w-full htw-flex htw-items-center htw-gap-1">
<span class="htw-block htw-grow htw-max-w-full">
<div class="htw-group htw-text-white htw-w-[16px] htw-h-[16px] htw-relative">
<div class="htw-border htw-border-solid group-active:htw-bg-gray-500/20 htw-rounded-sm htw-box-border htw-absolute htw-inset-0 htw-transition-border htw-duration-150 htw-ease-out group-hover:htw-border-primary-500 group-hover:dark:htw-border-primary-500 htw-border-black/25 dark:htw-border-white/25 htw-delay-150">
</div>
Expand Down Expand Up @@ -44,8 +44,8 @@ exports[`HstCheckbox toggle to unchecked 1`] = `
<span class="htw-w-28 htw-whitespace-nowrap htw-text-ellipsis htw-overflow-hidden htw-shrink-0 v-popper--has-tooltip">
Label
</span>
<span class="htw-grow htw-flex htw-items-center htw-gap-1">
<span class="htw-block htw-grow">
<span class="htw-grow htw-max-w-full htw-flex htw-items-center htw-gap-1">
<span class="htw-block htw-grow htw-max-w-full">
<div class="htw-group htw-text-white htw-w-[16px] htw-h-[16px] htw-relative">
<div class="htw-border htw-border-solid group-active:htw-bg-gray-500/20 htw-rounded-sm htw-box-border htw-absolute htw-inset-0 htw-transition-border htw-duration-150 htw-ease-out group-hover:htw-border-primary-500 group-hover:dark:htw-border-primary-500 htw-border-primary-500 htw-border-8">
</div>
Expand Down
41 changes: 41 additions & 0 deletions packages/histoire-controls/src/components/json/HstJson.story.vue
@@ -0,0 +1,41 @@
<script lang="ts" setup>
import HstJson from './HstJson.vue'
function initState () {
return {
film: {
year: 2017,
title: 'Blade Runner 2049',
actors: ['Ryan Gosling', 'Harrison Ford', 'Ana de Armas', 'Sylvia Hoeks'],
},
}
}
</script>

<template>
<Story
title="HstJson"
group="controls"
:layout="{ type: 'single', iframe: false }"
>
<Variant
title="default"
:init-state="initState"
>
<template #default="{ state }">
<HstJson
v-model="state.film"
title="Textarea"
/>
<pre>{{ state.film }}</pre>
</template>

<template #controls="{ state }">
<HstJson
v-model="state.film"
title="Text"
/>
</template>
</Variant>
</Story>
</template>
148 changes: 148 additions & 0 deletions packages/histoire-controls/src/components/json/HstJson.vue
@@ -0,0 +1,148 @@
<script lang="ts">
export default {
name: 'HstJson',
inheritAttrs: false,
}
</script>

<script lang="ts" setup>
import { ref, onMounted, watch, watchEffect } from 'vue'
import { Icon } from '@iconify/vue'
import HstWrapper from '../HstWrapper.vue'
import { VTooltip as vTooltip } from 'floating-vue'
import { Compartment } from '@codemirror/state'
import {
EditorView,
keymap,
highlightActiveLineGutter,
highlightActiveLine,
highlightSpecialChars,
ViewUpdate,
} from '@codemirror/view'
import { defaultKeymap } from '@codemirror/commands'
import { json } from '@codemirror/lang-json'
import {
defaultHighlightStyle,
syntaxHighlighting,
indentOnInput,
bracketMatching,
foldGutter,
foldKeymap,
} from '@codemirror/language'
import { lintKeymap } from '@codemirror/lint'
import { oneDarkTheme, oneDarkHighlightStyle } from '@codemirror/theme-one-dark'
import { isDark } from '../../utils'
const props = defineProps<{
title?: string
modelValue: unknown
}>()
const emits = defineEmits({
'update:modelValue': (newValue: unknown) => true,
})
let editorView: EditorView
const internalValue = ref('')
const invalidValue = ref(false)
const editorElement = ref<HTMLInputElement>()
const themes = {
light: [EditorView.baseTheme({}), syntaxHighlighting(defaultHighlightStyle)],
dark: [oneDarkTheme, syntaxHighlighting(oneDarkHighlightStyle)],
}
const themeConfig = new Compartment()
const extensions = [
highlightActiveLineGutter(),
highlightActiveLine(),
highlightSpecialChars(),
json(),
bracketMatching(),
indentOnInput(),
foldGutter(),
keymap.of([
...defaultKeymap,
...foldKeymap,
...lintKeymap,
]),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
internalValue.value = viewUpdate.view.state.doc.toString()
}),
themeConfig.of(themes.light),
]
onMounted(() => {
editorView = new EditorView({
doc: JSON.stringify(props.modelValue, null, 2),
extensions,
parent: editorElement.value,
})
watchEffect(() => {
editorView.dispatch({
effects: [
themeConfig.reconfigure(themes[isDark.value ? 'dark' : 'light']),
],
})
})
})
watch(() => props.modelValue, () => {
let sameDocument
try {
sameDocument = (JSON.stringify(JSON.parse(internalValue.value)) === JSON.stringify(props.modelValue))
} catch (e) {
sameDocument = false
}
if (!sameDocument) {
editorView.dispatch({ changes: [{ from: 0, to: editorView.state.doc.length, insert: JSON.stringify(props.modelValue, null, 2) }] })
}
}, { deep: true })
watch(() => internalValue.value, () => {
invalidValue.value = false
try {
emits('update:modelValue', JSON.parse(internalValue.value))
} catch (e) {
invalidValue.value = true
}
})
</script>

<template>
<HstWrapper
:title="title"
class="htw-cursor-text"
:class="$attrs.class"
:style="$attrs.style"
>
<div
ref="editorElement"
class="__histoire-json-code htw-w-full htw-border htw-border-solid htw-border-black/25 dark:htw-border-white/25 focus-within:htw-border-primary-500 dark:focus-within:htw-border-primary-500 htw-rounded-sm htw-box-border htw-overflow-auto htw-resize-y htw-min-h-32 htw-h-48 htw-relative"
v-bind="{ ...$attrs, class: null, style: null }"
/>

<template #actions>
<Icon
v-if="invalidValue"
v-tooltip="'JSON error'"
icon="carbon:warning-alt"
class="htw-text-orange-500"
/>

<slot name="actions" />
</template>
</HstWrapper>
</template>

<style scoped>
.__histoire-json-code :deep(.cm-editor) {
height: 100%;
min-width: 280px;
}
</style>
8 changes: 8 additions & 0 deletions packages/histoire-controls/src/end.d.ts
@@ -0,0 +1,8 @@
import type { Ref } from '@histoire/vendors/vue'

global {
interface Window {
__hst_controls_dark: Ref<boolean>[]
__hst_controls_dark_ready: () => void
}
}

0 comments on commit a9d9701

Please sign in to comment.