Skip to content

Commit ab8a385

Browse files
authored
Merge pull request #151 from ruchamahabal/styles
feat: Style panel improvements
2 parents b4396bf + 3044791 commit ab8a385

39 files changed

+1432
-602
lines changed

frontend/components.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ declare module 'vue' {
5959
Input: typeof import('./src/components/Input.vue')['default']
6060
InputLabel: typeof import('./src/components/InputLabel.vue')['default']
6161
ItemActions: typeof import('./src/components/ItemActions.vue')['default']
62+
ListBox: typeof import('./src/components/ListBox.vue')['default']
63+
LucideX: typeof import('~icons/lucide/x')['default']
6264
MarginHandler: typeof import('./src/components/MarginHandler.vue')['default']
6365
MarkdownEditor: typeof import('./src/components/AppLayout/MarkdownEditor.vue')['default']
6466
NewComponentDialog: typeof import('./src/components/NewComponentDialog.vue')['default']

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@
2626
"autoprefixer": "^10.4.2",
2727
"codemirror": "^6.0.1",
2828
"feather-icons": "^4.28.0",
29-
"frappe-ui": "0.1.245",
29+
"frappe-ui": "0.1.261",
3030
"json5": "^2.2.3",
3131
"marked": "^15.0.6",
3232
"pinia": "^2.2.1",
33+
"reka-ui": "^2.7.0",
3334
"thememirror": "^2.0.1",
3435
"typescript": "^5.8.3",
3536
"vite": "^5.4.11",

frontend/public/color-circle.png

3.69 KB
Loading
Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<component :is="tag" :class="[fontSize, fontWeight, lineHeight, textColor, 'transition-colors']">
2+
<component :is="tag" :class="[fontSize, 'transition-colors']">
33
{{ text }}
44
</component>
55
</template>
@@ -9,10 +9,7 @@ import type { TextBlockProps } from "@/types/studio_components/TextBlock"
99
1010
withDefaults(defineProps<TextBlockProps>(), {
1111
tag: "span",
12-
fontSize: "text-base",
13-
fontWeight: "font-normal",
14-
lineHeight: "leading-normal",
15-
textColor: "text-gray-900",
1612
text: "Text Block",
13+
fontSize: "text-base",
1714
})
1815
</script>
Lines changed: 205 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,231 @@
11
<!-- Extracted from Builder -->
22
<template>
3-
<div class="relative">
4-
<Combobox
5-
:modelValue="value"
6-
@update:modelValue="
7-
(val) => {
8-
emit('update:modelValue', val)
9-
}
10-
"
11-
v-slot="{ open }"
12-
:nullable="nullable"
13-
:multiple="multiple"
14-
>
3+
<ComboboxRoot
4+
v-model="selectedValue"
5+
v-model:open="isOpen"
6+
open-on-click
7+
open-on-focus
8+
:reset-search-term-on-blur="false"
9+
>
10+
<div class="relative" ref="containerRef">
1511
<div
16-
class="dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:bg-zinc-700 form-input flex h-7 w-full items-center justify-between gap-2 rounded px-2 py-1 pr-5 text-sm transition-colors"
12+
class="group form-input flex h-7 flex-1 items-center gap-2 rounded bg-surface-gray-2 p-0 text-sm text-ink-gray-8 transition-colors focus-within:bg-surface-white focus-within:ring-2 focus-within:ring-outline-gray-3"
1713
>
14+
<div v-if="$slots.prefix" class="flex items-center pl-2">
15+
<slot name="prefix" />
16+
</div>
1817
<ComboboxInput
18+
v-model="searchQuery"
1919
autocomplete="off"
20-
@change="query = $event.target.value"
21-
@focus="() => open"
22-
:displayValue="getDisplayValue"
23-
:placeholder="!modelValue ? placeholder : null"
24-
class="h-full w-full border-none bg-transparent p-0 text-base focus:border-none focus:ring-0"
20+
@focus="emit('focus')"
21+
@blur="handleBlur"
22+
@keydown.enter="handleEnter"
23+
:display-value="getDisplayValue"
24+
:placeholder="placeholder"
25+
class="h-full w-full flex-1 border-none bg-transparent px-0 text-base placeholder:text-ink-gray-4 focus:outline-none focus:ring-0"
26+
:class="{
27+
'pl-2': !$slots.prefix,
28+
'pr-2': !hasValue,
29+
}"
2530
/>
31+
<Button v-if="hasValue" variant="ghost" @click.stop="clearSelection" class="-ml-2">
32+
<CrossIcon class="h-3 w-3" />
33+
</Button>
2634
</div>
27-
<ComboboxOptions
28-
class="absolute right-0 z-50 max-h-[15rem] w-full overflow-y-auto rounded-lg bg-white px-1.5 py-1.5 shadow-2xl"
29-
v-show="filteredOptions.length"
35+
36+
<ComboboxContent
37+
class="absolute z-10 mt-1 max-h-80 w-full overflow-hidden rounded-lg border bg-surface-white shadow-xl"
3038
>
31-
<ComboboxOption v-if="query" :value="query" class="flex items-center"></ComboboxOption>
32-
<ComboboxOption
33-
v-slot="{ active, selected }"
34-
v-for="option in filteredOptions"
35-
:key="option.value"
36-
:value="option"
37-
class="flex items-center"
38-
>
39-
<li
40-
class="w-full select-none rounded px-2.5 py-1.5 text-xs"
41-
:class="{
42-
'bg-gray-100': active,
43-
'bg-gray-300': selected,
44-
}"
39+
<div class="overflow-y-auto p-1">
40+
<template v-for="(option, index) in displayOptions" :key="`${option.value}-${index}`">
41+
<ComboboxSeparator
42+
v-if="option.value.startsWith('_separator_line')"
43+
class="bg-outline-gray-2 mx-2 my-1 h-px"
44+
/>
45+
<ComboboxLabel
46+
v-else-if="option.value.startsWith('_separator')"
47+
class="px-2 py-1 text-xs font-semibold text-ink-gray-5"
48+
>
49+
{{ option.label }}
50+
</ComboboxLabel>
51+
<ComboboxItem
52+
v-else
53+
:value="option.value"
54+
:disabled="option.disabled"
55+
class="group flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-sm text-ink-gray-9 transition-colors data-[disabled]:pointer-events-none data-[highlighted]:bg-surface-gray-1 data-[disabled]:opacity-50"
56+
>
57+
<component v-if="option.prefix" :is="option.prefix" class="h-4 w-4 flex-shrink-0" />
58+
<span class="w-full flex-1 truncate">{{ option.label }}</span>
59+
<component
60+
v-if="option.suffix"
61+
:is="option.suffix"
62+
class="h-4 min-w-4 flex-shrink-0 opacity-60 group-hover:opacity-100"
63+
@mousedown.stop.prevent
64+
@click.stop.prevent
65+
/>
66+
</ComboboxItem>
67+
</template>
68+
</div>
69+
<div v-if="actionButton" class="border-t border-outline-gray-2 bg-surface-gray-1">
70+
<component v-if="actionButton.component" :is="actionButton.component" @change="refreshOptions" />
71+
<Button
72+
v-else
73+
:icon-left="actionButton.icon"
74+
variant="ghost"
75+
class="w-full justify-start rounded-none text-sm"
76+
@click="actionButton.handler"
4577
>
46-
{{ option.label }}
47-
</li>
48-
</ComboboxOption>
49-
</ComboboxOptions>
50-
</Combobox>
51-
<div
52-
class="dark:text-zinc-300 absolute right-[1px] top-[3px] cursor-pointer p-1 text-gray-700"
53-
@click="clearValue"
54-
v-show="modelValue"
55-
>
56-
<CrossIcon />
78+
{{ actionButton.label }}
79+
</Button>
80+
</div>
81+
</ComboboxContent>
5782
</div>
58-
</div>
83+
</ComboboxRoot>
5984
</template>
6085

6186
<script setup lang="ts">
62-
import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/vue"
63-
import { ComputedRef, computed, ref } from "vue"
87+
import { Button } from "frappe-ui"
6488
import CrossIcon from "@/components/Icons/Cross.vue"
89+
import {
90+
ComboboxContent,
91+
ComboboxInput,
92+
ComboboxItem,
93+
ComboboxLabel,
94+
ComboboxRoot,
95+
ComboboxSeparator,
96+
} from "reka-ui"
97+
import type { Component } from "vue"
98+
import { computed, ref, watch } from "vue"
6599
66-
import type { SelectOption } from "@/types"
100+
interface Option {
101+
label: string
102+
value: string
103+
prefix?: Component
104+
suffix?: Component
105+
disabled?: boolean
106+
}
67107
68-
const emit = defineEmits(["update:modelValue"])
108+
interface ActionButton {
109+
label: string
110+
handler: () => void
111+
icon: string
112+
component?: Component
113+
}
69114
70-
const props = withDefaults(
71-
defineProps<{
72-
options: SelectOption[]
73-
modelValue: string | string[]
74-
placeholder?: string
75-
showInputAsOption?: boolean
76-
}>(),
77-
{
78-
placeholder: "Search",
79-
},
80-
)
115+
interface Props {
116+
options?: Option[]
117+
getOptions?: (query: string) => Promise<Option[]>
118+
modelValue?: string | null
119+
placeholder?: string
120+
showInputAsOption?: boolean
121+
actionButton?: ActionButton
122+
allowArbitraryValue?: boolean
123+
}
124+
125+
const props = withDefaults(defineProps<Props>(), {
126+
options: () => [],
127+
placeholder: "Search",
128+
showInputAsOption: false,
129+
allowArbitraryValue: true,
130+
})
131+
132+
const emit = defineEmits<{
133+
"update:modelValue": [value: string | null]
134+
focus: []
135+
blur: []
136+
}>()
81137
82-
const query = ref("")
83-
84-
const multiple = computed(() => Array.isArray(props.modelValue))
85-
const nullable = computed(() => !multiple.value)
86-
87-
const value = computed(() => {
88-
return (
89-
props.options.find((option) => option.value === props.modelValue) || {
90-
label: props.modelValue,
91-
value: props.modelValue,
92-
}
93-
)
94-
}) as ComputedRef<SelectOption>
95-
96-
const filteredOptions = computed(() => {
97-
if (query.value === "") {
98-
return props.options
99-
} else {
100-
const options = props.options.filter((option) => {
101-
return (
102-
option.label.toLowerCase().includes(query.value.toLowerCase()) ||
103-
option.value.toLowerCase().includes(query.value.toLowerCase())
104-
)
105-
})
106-
if (props.showInputAsOption) {
107-
options.unshift({
108-
label: query.value,
109-
value: query.value,
110-
})
111-
}
112-
return options
138+
const containerRef = ref<HTMLElement | null>(null)
139+
const isOpen = ref(false)
140+
const searchQuery = ref("")
141+
const asyncOptions = ref<Option[]>([])
142+
const hasValue = computed(() => props.modelValue != null && props.modelValue !== "")
143+
const allOptions = computed(() => (props.getOptions ? asyncOptions.value : props.options))
144+
145+
const displayOptions = computed(() => {
146+
let options = allOptions.value
147+
if (
148+
props.showInputAsOption &&
149+
searchQuery.value &&
150+
!options.some((opt) => opt.value === searchQuery.value)
151+
) {
152+
options = [{ label: searchQuery.value, value: searchQuery.value }, ...options]
113153
}
154+
return options
114155
})
115156
116-
const clearValue = () => emit("update:modelValue", null)
157+
const selectedValue = computed({
158+
get: () => props.modelValue,
159+
set: (value) => {
160+
emit("update:modelValue", value ?? null)
161+
isOpen.value = false
162+
},
163+
})
164+
165+
const getDisplayValue = (item: any): string => {
166+
if (typeof item === "object") return item?.label || item?.value || ""
167+
const found = allOptions.value.find((opt) => opt.value === item)
168+
return found?.label || item || ""
169+
}
170+
171+
const refreshOptions = async (query = "") => {
172+
if (!props.getOptions) return
173+
try {
174+
asyncOptions.value = await props.getOptions(query)
175+
} catch (error) {
176+
console.error("Failed to load options:", error)
177+
}
178+
}
179+
180+
const clearSelection = () => emit("update:modelValue", null)
181+
182+
const getInputValue = (event: Event) => (event.target as HTMLInputElement)?.value?.trim()
183+
184+
const submitArbitraryValue = (inputValue: string) => {
185+
if (!inputValue) return
186+
const matchingOption = allOptions.value.find((opt) => opt.label.toLowerCase() === inputValue.toLowerCase())
187+
emit("update:modelValue", matchingOption?.value ?? inputValue)
188+
isOpen.value = false
189+
}
190+
191+
const handleEnter = (event: KeyboardEvent) => {
192+
if (!props.allowArbitraryValue) return
193+
const highlightedItem = containerRef.value?.querySelector("[data-highlighted]")
194+
const inputValue = getInputValue(event)
195+
// If there's a highlighted item and user hasn't typed anything different, let the combobox handle it
196+
if (highlightedItem && !inputValue) return
197+
// If user typed something, check if it matches the highlighted item's value
198+
if (highlightedItem && inputValue) {
199+
const highlightedValue = highlightedItem.getAttribute("data-value")
200+
const matchingOption = allOptions.value.find((opt) => opt.value === highlightedValue)
201+
// If input matches highlighted item's label, let combobox handle it
202+
if (matchingOption && matchingOption.label.toLowerCase() === inputValue.toLowerCase()) return
203+
}
204+
event.preventDefault()
205+
event.stopPropagation()
206+
submitArbitraryValue(inputValue)
207+
}
117208
118-
const getDisplayValue = (option: SelectOption | SelectOption[]) => {
119-
if (Array.isArray(option)) {
120-
return option.map((o) => o.label).join(", ")
121-
} else if (option) {
122-
return option.label || option.value || ""
123-
} else {
124-
return ""
209+
const handleBlur = (event: FocusEvent) => {
210+
const relatedTarget = event.relatedTarget as HTMLElement
211+
if (relatedTarget && containerRef.value?.contains(relatedTarget)) {
212+
emit("blur")
213+
return
125214
}
215+
if (props.allowArbitraryValue) submitArbitraryValue(getInputValue(event))
216+
emit("blur")
126217
}
218+
219+
watch(searchQuery, (query) => props.getOptions && refreshOptions(query))
220+
watch(
221+
() => props.modelValue,
222+
(val) => (searchQuery.value = val ?? ""),
223+
{ immediate: true },
224+
)
225+
if (props.getOptions) refreshOptions()
226+
227+
defineExpose({
228+
refreshOptions,
229+
clearSelection,
230+
})
127231
</script>

0 commit comments

Comments
 (0)