Skip to content

Commit 558f23b

Browse files
committed
Add TagInput + TextLink components
1 parent e5137fb commit 558f23b

File tree

6 files changed

+191
-23
lines changed

6 files changed

+191
-23
lines changed

src/components/Icon.vue

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
<template>
2-
<span v-if="isSvg" v-html="svgHtml()"></span>
2+
<svg v-if="isSvg" :class="image?.cls" v-html="svgEl?.innerHTML" v-bind="svgEl?.attrs"></svg>
33
<img v-else="imgSrc" :class="image?.cls" :src="assetsPathResolver(imgSrc)" @error="iconOnError($event.target as HTMLImageElement)">
44
</template>
55

66
<script setup lang="ts">
7-
import { computed, useAttrs } from 'vue'
87
import type { ImageInfo } from '@/types'
9-
import { classNames, htmlAttrs, map } from '@servicestack/client'
8+
import { computed } from 'vue'
9+
import { map } from '@servicestack/client'
1010
import { useConfig, useFiles } from '@/api'
11+
import { parseHtml } from './utils'
1112
1213
const props = defineProps<{
1314
image?: ImageInfo
@@ -20,17 +21,8 @@ const { assetsPathResolver } = useConfig()
2021
const { iconOnError } = useFiles()
2122
2223
const isSvg = computed(() => map(props.svg || props.image?.svg, x => x.startsWith('<svg ')))
23-
const $attrs = useAttrs()
2424
25-
function svgHtml() {
26-
const svg = props.svg || props.image?.svg
27-
if (svg && svg.startsWith('<svg ')) {
28-
const cls = classNames(props.image?.cls, $attrs.class)
29-
const svgAttrs = Object.assign({}, { class:cls, role:'img', 'aria-hidden':'true' }, $attrs)
30-
return `<svg ${htmlAttrs(svgAttrs)} ` + svg.substring(4)
31-
}
32-
return null
33-
}
25+
const svgEl = computed(() => isSvg.value ? parseHtml(props.svg || props.image?.svg || '') : null)
3426
3527
const imgSrc = computed(() => props.src || props.image?.uri)
3628

src/components/TagInput.vue

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<template>
2+
<div>
3+
<label v-if="useLabel" :for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ useLabel }}</label>
4+
<div class="mt-1 relative rounded-md shadow-sm">
5+
<button :class="cls" @click.prevent="handleClick">
6+
<div class="flex flex-wrap pb-1.5">
7+
<div v-for="tag in modelValue" class="pt-1.5 pl-1">
8+
<span class="inline-flex rounded-full items-center py-0.5 pl-2.5 pr-1 text-sm font-medium bg-indigo-100 dark:bg-indigo-800 text-indigo-700 dark:text-indigo-300">
9+
{{tag}}
10+
<button type="button" @click="removeTag(tag)" class="flex-shrink-0 ml-1 h-4 w-4 rounded-full inline-flex items-center justify-center text-indigo-400 dark:text-indigo-500 hover:bg-indigo-200 dark:hover:bg-indigo-800 hover:text-indigo-500 dark:hover:text-indigo-400 focus:outline-none focus:bg-indigo-500 focus:text-white dark:focus:text-black">
11+
<svg class="h-2 w-2" stroke="currentColor" fill="none" viewBox="0 0 8 8"><path stroke-linecap="round" stroke-width="1.5" d="M1 1l6 6m0-6L1 7"></path></svg>
12+
</button>
13+
</span>
14+
</div>
15+
<div class="pt-1.5 pl-1 shrink">
16+
<input ref="txtInput" :type="useType"
17+
:name="id"
18+
:id="id"
19+
class="p-0 dark:bg-transparent rounded-md border-none focus:!border-none focus:!outline-none"
20+
:style="`box-shadow:none !important;width:${inputValue.length + 1}ch`"
21+
v-model="inputValue"
22+
:aria-invalid="errorField != null"
23+
:aria-describedby="`${id}-error`"
24+
@keydown="keyDown"
25+
@keypress="keyPress"
26+
@paste.prevent.stop="onPaste"
27+
@blur="onBlur"
28+
v-bind="remaining">
29+
</div>
30+
</div>
31+
</button>
32+
33+
<div v-if="errorField" class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
34+
<svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
35+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
36+
</svg>
37+
</div>
38+
</div>
39+
40+
<p v-if="errorField" class="mt-2 text-sm text-red-500" :id="`${id}-error`">{{ errorField }}</p>
41+
<p v-else-if="help" class="mt-2 text-sm text-gray-500" :id="`${id}-description`">{{ help }}</p>
42+
</div>
43+
</template>
44+
45+
<script setup lang="ts">
46+
import { errorResponse, humanize, omit, ResponseStatus, toPascalCase, trimEnd } from "@servicestack/client"
47+
import { computed, inject, ref, useAttrs } from "vue"
48+
import type { ApiState } from "../types"
49+
50+
//const value = (e:EventTarget|null) => (e as HTMLInputElement).value //workaround IDE type-check error
51+
52+
const props = withDefaults(defineProps<{
53+
status?: ResponseStatus|null
54+
id: string
55+
type?: string
56+
label?: string
57+
help?: string
58+
modelValue?: string[],
59+
delimiters?: string[],
60+
}>(), {
61+
modelValue: () => [],
62+
delimiters: () => [','],
63+
})
64+
65+
const emit = defineEmits<{
66+
(e: "update:modelValue", value: string[]): void
67+
}>()
68+
69+
const txtInput = ref<HTMLInputElement|null>(null)
70+
const inputValue = ref('')
71+
72+
const useType = computed(() => props.type || 'text')
73+
const useLabel = computed(() => props.label ?? humanize(toPascalCase(props.id)))
74+
75+
const remaining = computed(() => omit(useAttrs(), [...Object.keys(props)]))
76+
77+
let ctx: ApiState|undefined = inject('ApiState', undefined)
78+
const errorField = computed(() => errorResponse.call({ responseStatus: props.status ?? ctx?.error.value }, props.id))
79+
80+
const cls = computed(() => ['w-full cursor-text flex flex-wrap sm:text-sm rounded-md dark:text-white dark:bg-gray-900 border focus-within:border-transparent focus-within:ring-1 focus-within:outline-none', errorField.value
81+
? 'pr-10 border-red-300 text-red-900 placeholder-red-300 focus-within:outline-none focus-within:ring-red-500 focus-within:border-red-500'
82+
: 'shadow-sm border-gray-300 dark:border-gray-600 focus-within:ring-indigo-500 focus-within:border-indigo-500'])
83+
84+
const handleClick = (e:MouseEvent) => txtInput.value?.focus()
85+
const updateValue = (newValue:string[]) => emit('update:modelValue', newValue)
86+
const removeTag = (tag:string) => updateValue(props.modelValue.filter(x => x != tag))
87+
const onBlur = () => keyPress({ key:'Enter' } as any)
88+
89+
function keyDown(e:KeyboardEvent) {
90+
if (e.key == "Backspace" && inputValue.value.length == 0) {
91+
if (props.modelValue.length > 0) {
92+
removeTag(props.modelValue[props.modelValue.length - 1])
93+
}
94+
}
95+
}
96+
97+
function keyPress(e:KeyboardEvent) {
98+
if (inputValue.value.length == 0) return
99+
const tag = trimEnd(inputValue.value.trim(), ',').trim()
100+
if (tag.length == 0) return
101+
const isEnter = e.key == "Enter" || e.key == "NumpadEnter"
102+
if (isEnter || (e.key.length == 1 && props.delimiters.some(x => x == e.key))) {
103+
const newValue = Array.from(props.modelValue)
104+
if (newValue.indexOf(tag) == -1) {
105+
newValue.push(tag)
106+
}
107+
updateValue(newValue)
108+
inputValue.value = ''
109+
}
110+
}
111+
112+
function onPaste(e:ClipboardEvent) {
113+
const text = e.clipboardData?.getData('Text')
114+
handlePastedText(text)
115+
}
116+
117+
function handlePastedText(txt?:string) {
118+
if (!txt) return
119+
const re = new RegExp(`\\n|\\t|${props.delimiters.join('|')}`)
120+
const newTags = Array.from(props.modelValue)
121+
const tags = txt.split(re).map(x => x.trim())
122+
tags.forEach(tag => {
123+
if (newTags.indexOf(tag) == -1) {
124+
newTags.push(tag)
125+
}
126+
})
127+
updateValue(newTags)
128+
inputValue.value = ''
129+
}
130+
131+
132+
</script>

src/components/TextLink.vue

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<template>
2+
<a :class="cls"><slot></slot></a>
3+
</template>
4+
5+
<script setup lang="ts">import { computed } from 'vue';
6+
7+
const props = withDefaults(defineProps<{
8+
color?: "blue" | "purple" | "red" | "green" | "sky" | "cyan" | "indigo"
9+
}>(), {
10+
color: 'blue'
11+
})
12+
13+
const colors = {
14+
blue: 'text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200',
15+
purple: 'text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200',
16+
red: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200',
17+
green: 'text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200',
18+
sky: 'text-sky-600 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-200',
19+
cyan: 'text-cyan-600 dark:text-cyan-400 hover:text-cyan-800 dark:hover:text-cyan-200',
20+
indigo: 'text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-200',
21+
}
22+
23+
const cls = computed(() => colors[props.color] || colors.blue)
24+
25+
</script>

src/components/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import Loading from './Loading.vue'
2424
import Icon from './Icon.vue'
2525
import NavList from './NavList.vue'
2626
import NavListItem from './NavListItem.vue'
27+
import TagInput from './TagInput.vue'
28+
import TextLink from './TextLink.vue'
2729

2830
export default {
2931
CheckboxInput,
@@ -51,4 +53,6 @@ export default {
5153
Icon,
5254
NavList,
5355
NavListItem,
56+
TagInput,
57+
TextLink,
5458
}

src/components/utils.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,26 @@ export function transition(rule:TransitionRules, transition:Ref<string>, show:bo
1313
}
1414

1515
interface ParsedHtml {
16-
tag: string
17-
outerHTML: string
16+
tagName: string
17+
attrs: {[k:string]:string|null}
1818
innerHTML: string
19-
attrs: {[k:string]:any}
2019
}
2120

2221
const CACHE:{[k:string]:ParsedHtml} = {}
2322

24-
export function parseHtml(html:string, attrs?:any) {
23+
export function parseHtml(html:string) {
2524
let existing = CACHE[html]
2625
if (existing) return existing
27-
const elAttrs: {[k:string]:string} = Object.assign({},attrs)
26+
const elAttrs: {[k:string]:string|null} = {}
2827
const outer = document.createElement('div')
2928
outer.innerHTML = html
3029
const el = outer.firstElementChild
3130
el!.getAttributeNames().forEach(name => {
3231
const val = el!.getAttribute(name)
33-
if (val) elAttrs[name] = val
32+
elAttrs[name] = val
3433
})
3534
return CACHE[html] = {
36-
tag: el!.tagName,
37-
outerHTML: el!.outerHTML,
35+
tagName: el!.tagName,
3836
innerHTML: el!.innerHTML,
3937
attrs: elAttrs,
4038
}

src/demo/App.vue

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,24 @@
121121

122122
<div class="mx-auto max-w-4xl space-x-2">
123123
<h1 class="my-8 text-3xl">Inputs</h1>
124-
<div class="max-w-xl">
124+
<div class="max-w-xl mb-4">
125125
<FileInput id="single" />
126126
<FileInput id="multiple" multiple />
127-
<Loading>Custom Loading...</Loading>
127+
<TagInput id="tags" v-model="tags" />
128+
<div class="mt-2 flex flex-wrap">
129+
<b>Tags: </b>
130+
<span class="ml-2" v-for="tag in tags">{{ tag }}</span>
131+
</div>
132+
</div>
133+
<Loading>Custom Loading...</Loading>
134+
<div class="flex space-x-2">
135+
<p><TextLink href="https://google.com" target="_blank" title="Google Link" @click="onClick('here')">Blue <b>Link</b></TextLink></p>
136+
<p><TextLink color="purple" href="https://google.com" target="_blank" title="Google Link">Purple <b>Link</b></TextLink></p>
137+
<p><TextLink color="red" href="https://google.com" target="_blank" title="Google Link">Red <b>Link</b></TextLink></p>
138+
<p><TextLink color="green" href="https://google.com" target="_blank" title="Google Link">Green <b>Link</b></TextLink></p>
139+
<p><TextLink color="sky" href="https://google.com" target="_blank" title="Google Link">Sky <b>Link</b></TextLink></p>
140+
<p><TextLink color="cyan" href="https://google.com" target="_blank" title="Google Link">Cyan <b>Link</b></TextLink></p>
141+
<p><TextLink color="indigo" href="https://google.com" target="_blank" title="Google Link">Indigo <b>Link</b></TextLink></p>
128142
</div>
129143
</div>
130144

@@ -145,6 +159,7 @@
145159

146160
<div class="mx-auto max-w-4xl">
147161
<h1 class="my-8 text-3xl">NavList</h1>
162+
<Icon class="w-24 h-24" src="https://cdn.diffusion.works/artifacts/2023/01/26/9060157/output_77487570.png" />
148163
<NavList title="Explore Blazor Components">
149164
<NavListItem title="DataGrid" href="/gallery/datagrid" :iconSvg="Icons.DataGrid">
150165
DataGrid Component Examples for rendering tabular data
@@ -197,6 +212,7 @@ const modal = ref(false)
197212
const loading = ref(false)
198213
const lateCheckout = ref(false)
199214
const ensureAccess = ref(false)
215+
const tags = ref(['red','green','blue'])
200216
201217
const client = useClient()
202218
@@ -225,6 +241,7 @@ const Icons = {
225241
const bookingIcon = { svg: Icons.Booking }
226242
const couponIcon = { svg: Icons.Coupon }
227243
244+
const onClick = msg => alert(msg)
228245
</script>
229246

230247
<style>

0 commit comments

Comments
 (0)