Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add JSON control #319

Merged
merged 15 commits into from
Oct 9, 2022
5 changes: 5 additions & 0 deletions examples/vue3/src/components/Controls.story.vue
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Ref } from '@histoire/vendors/vue'

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