|
| 1 | +<script setup lang="ts"> |
| 2 | +import { computed, onMounted, onUnmounted, ref } from 'vue'; |
| 3 | +
|
| 4 | +import N8nIcon from '../../components/N8nIcon/Icon.vue'; |
| 5 | +import N8nInput from '../../components/N8nInput/Input.vue'; |
| 6 | +import tokensSource from '../../css/_tokens.scss?raw'; |
| 7 | +import { getColorTokenNames } from '../utils/cssTokenSource'; |
| 8 | +
|
| 9 | +const SEMANTIC_COLOR_TOKENS = getColorTokenNames(tokensSource); |
| 10 | +
|
| 11 | +type TokenGroup = { |
| 12 | + label: string; |
| 13 | + tokens: string[]; |
| 14 | +}; |
| 15 | +
|
| 16 | +const GROUP_ORDER = ['Text Color', 'Background', 'Border Color', 'Icon Color', 'Color', 'Focus']; |
| 17 | +
|
| 18 | +const query = ref(''); |
| 19 | +const tokenValues = ref<Record<string, string>>({}); |
| 20 | +
|
| 21 | +let observer: MutationObserver | null = null; |
| 22 | +let colorSchemeQuery: MediaQueryList | null = null; |
| 23 | +
|
| 24 | +const groupLabelFor = (token: string) => { |
| 25 | + const firstGroup = token.slice(2).split('--')[0] ?? token; |
| 26 | + return firstGroup |
| 27 | + .split('-') |
| 28 | + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) |
| 29 | + .join(' '); |
| 30 | +}; |
| 31 | +
|
| 32 | +const allNamedTokens = computed(() => |
| 33 | + [...SEMANTIC_COLOR_TOKENS].sort((a, b) => a.localeCompare(b)), |
| 34 | +); |
| 35 | +
|
| 36 | +const filteredTokens = computed(() => { |
| 37 | + const needle = query.value.trim().toLowerCase(); |
| 38 | +
|
| 39 | + if (!needle) { |
| 40 | + return allNamedTokens.value; |
| 41 | + } |
| 42 | +
|
| 43 | + return allNamedTokens.value.filter((token) => { |
| 44 | + const value = tokenValues.value[token] ?? ''; |
| 45 | + return token.toLowerCase().includes(needle) || value.toLowerCase().includes(needle); |
| 46 | + }); |
| 47 | +}); |
| 48 | +
|
| 49 | +const groupedTokens = computed((): TokenGroup[] => { |
| 50 | + const groups = new Map<string, string[]>(); |
| 51 | +
|
| 52 | + for (const token of filteredTokens.value) { |
| 53 | + const label = groupLabelFor(token); |
| 54 | + const tokens = groups.get(label) ?? []; |
| 55 | + tokens.push(token); |
| 56 | + groups.set(label, tokens); |
| 57 | + } |
| 58 | +
|
| 59 | + return [...groups.entries()] |
| 60 | + .map(([label, tokens]) => ({ label, tokens })) |
| 61 | + .sort((a, b) => { |
| 62 | + const aOrder = GROUP_ORDER.indexOf(a.label); |
| 63 | + const bOrder = GROUP_ORDER.indexOf(b.label); |
| 64 | +
|
| 65 | + if (aOrder !== bOrder) { |
| 66 | + if (aOrder === -1) { |
| 67 | + return 1; |
| 68 | + } |
| 69 | + if (bOrder === -1) { |
| 70 | + return -1; |
| 71 | + } |
| 72 | + return aOrder - bOrder; |
| 73 | + } |
| 74 | +
|
| 75 | + return a.label.localeCompare(b.label); |
| 76 | + }); |
| 77 | +}); |
| 78 | +
|
| 79 | +const updateValues = () => { |
| 80 | + const style = getComputedStyle(document.body); |
| 81 | + const nextValues: Record<string, string> = {}; |
| 82 | +
|
| 83 | + for (const token of SEMANTIC_COLOR_TOKENS) { |
| 84 | + nextValues[token] = style.getPropertyValue(token).trim(); |
| 85 | + } |
| 86 | +
|
| 87 | + tokenValues.value = nextValues; |
| 88 | +}; |
| 89 | +
|
| 90 | +onMounted(() => { |
| 91 | + updateValues(); |
| 92 | +
|
| 93 | + observer = new MutationObserver((mutationsList) => { |
| 94 | + for (const mutation of mutationsList) { |
| 95 | + if (mutation.type === 'attributes') { |
| 96 | + updateValues(); |
| 97 | + } |
| 98 | + } |
| 99 | + }); |
| 100 | +
|
| 101 | + observer.observe(document.body, { attributes: true }); |
| 102 | +
|
| 103 | + if (typeof window.matchMedia === 'function') { |
| 104 | + colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); |
| 105 | + colorSchemeQuery.addEventListener('change', updateValues); |
| 106 | + } |
| 107 | +}); |
| 108 | +
|
| 109 | +onUnmounted(() => { |
| 110 | + observer?.disconnect(); |
| 111 | + colorSchemeQuery?.removeEventListener('change', updateValues); |
| 112 | +}); |
| 113 | +</script> |
| 114 | + |
| 115 | +<template> |
| 116 | + <div :class="$style.container"> |
| 117 | + <div :class="$style.search"> |
| 118 | + <N8nInput v-model="query" size="small" placeholder="Search" clearable> |
| 119 | + <template #prefix> |
| 120 | + <N8nIcon icon="search" :size="14" /> |
| 121 | + </template> |
| 122 | + </N8nInput> |
| 123 | + </div> |
| 124 | + |
| 125 | + <div v-if="filteredTokens.length === 0" :class="$style.empty"> |
| 126 | + No named tokens match that search. |
| 127 | + </div> |
| 128 | + |
| 129 | + <section v-for="group in groupedTokens" :key="group.label" :class="$style.group"> |
| 130 | + <div :class="$style.groupLabel">{{ group.label }}</div> |
| 131 | + <ul :class="$style.list"> |
| 132 | + <li v-for="token in group.tokens" :key="token" :class="$style.item"> |
| 133 | + <div :class="$style.row"> |
| 134 | + <span :class="$style.swatch" aria-hidden="true"> |
| 135 | + <span :class="$style.swatchFill" :style="{ background: `var(${token})` }" /> |
| 136 | + </span> |
| 137 | + <span :class="$style.name">{{ token }}</span> |
| 138 | + <span :class="$style.value">{{ tokenValues[token] }}</span> |
| 139 | + </div> |
| 140 | + </li> |
| 141 | + </ul> |
| 142 | + </section> |
| 143 | + </div> |
| 144 | +</template> |
| 145 | + |
| 146 | +<style lang="scss" module> |
| 147 | +.container { |
| 148 | + display: flex; |
| 149 | + flex-direction: column; |
| 150 | + gap: var(--spacing--lg); |
| 151 | + margin: var(--spacing--xl) 0; |
| 152 | +} |
| 153 | +
|
| 154 | +.search { |
| 155 | + width: fit-content; |
| 156 | + max-width: 100%; |
| 157 | +
|
| 158 | + > :global(*) { |
| 159 | + width: auto; |
| 160 | + } |
| 161 | +
|
| 162 | + :global(input) { |
| 163 | + flex: none; |
| 164 | + width: 20ch; |
| 165 | + } |
| 166 | +} |
| 167 | +
|
| 168 | +.empty, |
| 169 | +.groupLabel, |
| 170 | +.name, |
| 171 | +.value { |
| 172 | + margin: 0; |
| 173 | + color: var(--text-color--subtle); |
| 174 | +} |
| 175 | +
|
| 176 | +.empty { |
| 177 | + font-size: var(--font-size--sm); |
| 178 | +} |
| 179 | +
|
| 180 | +.group { |
| 181 | + display: flex; |
| 182 | + flex-direction: column; |
| 183 | + gap: var(--spacing--xs); |
| 184 | +} |
| 185 | +
|
| 186 | +.groupLabel { |
| 187 | + font-size: var(--font-size--sm); |
| 188 | + font-weight: var(--font-weight--medium); |
| 189 | + color: var(--text-color); |
| 190 | +} |
| 191 | +
|
| 192 | +.list { |
| 193 | + display: flex; |
| 194 | + flex-direction: column; |
| 195 | + gap: var(--spacing--4xs); |
| 196 | + margin: 0; |
| 197 | + padding: 0; |
| 198 | + list-style: none; |
| 199 | +} |
| 200 | +
|
| 201 | +.item { |
| 202 | + display: block; |
| 203 | +} |
| 204 | +
|
| 205 | +.row { |
| 206 | + display: grid; |
| 207 | + grid-template-columns: var(--spacing--xl) minmax(0, 1.2fr) minmax(0, 1fr); |
| 208 | + align-items: center; |
| 209 | + gap: var(--spacing--sm); |
| 210 | + width: 100%; |
| 211 | +} |
| 212 | +
|
| 213 | +.swatch { |
| 214 | + display: block; |
| 215 | + width: var(--spacing--xl); |
| 216 | + height: var(--spacing--xl); |
| 217 | + border-radius: var(--radius); |
| 218 | + box-shadow: var(--shadow--outline); |
| 219 | + background-color: var(--background--surface); |
| 220 | + overflow: hidden; |
| 221 | +} |
| 222 | +
|
| 223 | +.swatchFill { |
| 224 | + display: inline-flex; |
| 225 | + align-items: center; |
| 226 | + justify-content: center; |
| 227 | + width: 100%; |
| 228 | + height: 100%; |
| 229 | +} |
| 230 | +
|
| 231 | +.name, |
| 232 | +.value { |
| 233 | + font-family: var(--font-family--monospace); |
| 234 | + font-size: var(--font-size--2xs); |
| 235 | + line-height: var(--line-height--sm); |
| 236 | + overflow: hidden; |
| 237 | + text-overflow: ellipsis; |
| 238 | + white-space: nowrap; |
| 239 | +} |
| 240 | +
|
| 241 | +.name { |
| 242 | + color: var(--text-color); |
| 243 | +} |
| 244 | +
|
| 245 | +.value { |
| 246 | + color: var(--text-color--subtle); |
| 247 | +} |
| 248 | +</style> |
0 commit comments