From 464e746a93e3bf610542f98863b70e7969eb5509 Mon Sep 17 00:00:00 2001 From: yihao Date: Thu, 29 Jan 2026 17:00:34 +0800 Subject: [PATCH 01/12] feat: add custom tag order and colors for CardStack component - Add syntax parsing in MdAttributeRenderer to extract tag configurations - Implement processCardStackAttributes() to parse elements with name and color - Add tagConfigs and dataTagConfigs props to CardStack component - Implement tag ordering algorithm that respects custom config order - Add color normalization supporting both hex colors and Bootstrap color names - Update badge rendering with conditional styling (Bootstrap classes vs inline styles) - Add isBootstrapColor() and getTextColor() helper methods for color handling - Apply same color logic to Card component for visual consistency - Add comprehensive test coverage for custom tag ordering and colors - Update documentation with examples for hex colors and Bootstrap color names - Document new and element syntax and options --- docs/userGuide/syntax/cardstacks.md | 116 +++++++++++++++ packages/core/src/html/MdAttributeRenderer.ts | 50 +++++++ packages/core/src/html/NodeProcessor.ts | 3 + .../src/__tests__/CardStack.spec.js | 132 ++++++++++++++++++ .../vue-components/src/cardstack/Card.vue | 34 ++++- .../src/cardstack/CardStack.vue | 114 ++++++++++++++- 6 files changed, 446 insertions(+), 3 deletions(-) diff --git a/docs/userGuide/syntax/cardstacks.md b/docs/userGuide/syntax/cardstacks.md index c672f1c6eb..e6ec399400 100644 --- a/docs/userGuide/syntax/cardstacks.md +++ b/docs/userGuide/syntax/cardstacks.md @@ -135,6 +135,113 @@ In the example given below, a Card Stack is used to show a list of questions and The example above also illustrates how to use the `keywords` attribute to specify additional search terms for a card. +### Custom Tag Order and Colors + +You can customize the order and colors of tags by using a `` element inside the `cardstack`: + + +html + + + + + + + + + + + + Success is not final, failure is not fatal: it is the courage to continue that counts + + + In the middle of every difficulty lies opportunity + + + Do what you can, with what you have, where you are + + + Your time is limited, so don't waste it living someone else's life + + + + + + + + + + + + + + + Success is not final, failure is not fatal: it is the courage to continue that counts + + + In the middle of every difficulty lies opportunity + + + Do what you can, with what you have, where you are + + + Your time is limited, so don't waste it living someone else's life + + + + + +You can also use Bootstrap color names instead of hex colors: + + +html + + + + + + + + + + Success is not final, failure is not fatal: it is the courage to continue that counts + + + In the middle of every difficulty lies opportunity + + + Do what you can, with what you have, where you are + + + + + + + + + + + + + Success is not final, failure is not fatal: it is the courage to continue that counts + + + In the middle of every difficulty lies opportunity + + + Do what you can, with what you have, where you are + + + + + +The `` element allows you to: +- Specify the order in which tags appear in the filter badges +- Assign custom colors to each tag using either: + - Hex format (e.g., `#28a745`) + - Bootstrap color names (e.g., `success`, `danger`, `primary`, `warning`, `info`, `secondary`, `light`, `dark`) +- Any tags used in cards but not defined in `` will appear after the defined tags with default colors + ****Options**** `cardstack`: @@ -144,6 +251,15 @@ blocks | `String` | `2` | Number of `card` columns per row.
Supports: `1`, ` searchable | `Boolean` | `false` | Whether the card stack is searchable. show-select-all | `Boolean` | `true` | Whether the select all tag button appears. (`false` by default if total tags ≤ 3) +`tags` (optional): +A container element inside `cardstack` to define tag ordering and colors. + +`tag` (inside `tags` element): +Name | Type | Default | Description +--- | --- | --- | --- +name | `String` | (required) | The name of the tag (must match tags used in cards). +color | `String` | (auto) | Custom color for the tag.
Supports hex format (e.g., `#28a745`) or Bootstrap color names (e.g., `success`, `danger`, `primary`).
If not specified, uses default Bootstrap color scheme. + `card`: Name | Type | Default | Description --- | --- | --- | --- diff --git a/packages/core/src/html/MdAttributeRenderer.ts b/packages/core/src/html/MdAttributeRenderer.ts index 442689d883..cae5fffe85 100644 --- a/packages/core/src/html/MdAttributeRenderer.ts +++ b/packages/core/src/html/MdAttributeRenderer.ts @@ -167,6 +167,56 @@ export class MdAttributeRenderer { this.processSlotAttribute(node, 'header', true); } + // eslint-disable-next-line class-methods-use-this + processCardStackAttributes(node: MbNode) { + // Look for a child element + if (!node.children) { + return; + } + + const tagsNodeIndex = node.children.findIndex( + child => child.type === 'tag' && (child as MbNode).name === 'tags', + ); + + if (tagsNodeIndex === -1) { + return; + } + + const tagsNode = node.children[tagsNodeIndex] as MbNode; + const tagConfigs: Array<{ name: string; color?: string }> = []; + + // Parse each element + if (tagsNode.children) { + tagsNode.children.forEach((child) => { + if (child.type === 'tag' && (child as MbNode).name === 'tag') { + const tagNode = child as MbNode; + if (tagNode.attribs && tagNode.attribs.name) { + const config: { name: string; color?: string } = { + name: tagNode.attribs.name, + }; + if (tagNode.attribs.color) { + config.color = tagNode.attribs.color; + } + tagConfigs.push(config); + } + } + }); + } + + // Add tag-configs as a prop if we found any tags + // Store as a data attribute that will be parsed by the Vue component + // We need to escape the quotes for HTML attributes to prevent SSR warnings + if (tagConfigs.length > 0) { + const jsonString = JSON.stringify(tagConfigs); + // Replace double quotes with HTML entities to avoid SSR warnings + const escapedJson = jsonString.replace(/"/g, '"'); + node.attribs['data-tag-configs'] = escapedJson; + } + + // Remove the node from the DOM tree + node.children.splice(tagsNodeIndex, 1); + } + /* * Dropdowns */ diff --git a/packages/core/src/html/NodeProcessor.ts b/packages/core/src/html/NodeProcessor.ts index ee8e8ec8d4..453be9db63 100644 --- a/packages/core/src/html/NodeProcessor.ts +++ b/packages/core/src/html/NodeProcessor.ts @@ -214,6 +214,9 @@ export class NodeProcessor { case 'card': this.mdAttributeRenderer.processCardAttributes(node); break; + case 'cardstack': + this.mdAttributeRenderer.processCardStackAttributes(node); + break; case 'modal': this.processModal(node); break; diff --git a/packages/vue-components/src/__tests__/CardStack.spec.js b/packages/vue-components/src/__tests__/CardStack.spec.js index 8589fe2a9f..dc66604179 100644 --- a/packages/vue-components/src/__tests__/CardStack.spec.js +++ b/packages/vue-components/src/__tests__/CardStack.spec.js @@ -42,6 +42,12 @@ const MARKDOWN_CARDS = ` `; +const CARDS_WITH_CUSTOM_TAGS = ` + + + +`; + describe('CardStack', () => { test('should not hide cards when no filter is provided', async () => { const wrapper = mount(CardStack, { @@ -228,4 +234,130 @@ describe('CardStack', () => { const selectAllBadge = wrapper.find('.select-all-toggle'); expect(selectAllBadge.exists()).toBe(false); }); + + test('should respect custom tag order from tag-configs', async () => { + const tagConfigs = JSON.stringify([ + { name: 'Neutral', color: '#6c757d' }, + { name: 'Success', color: '#28a745' }, + { name: 'Failure', color: '#dc3545' }, + ]); + const wrapper = mount(CardStack, { + propsData: { + dataTagConfigs: tagConfigs.replace(/"/g, '"'), + }, + slots: { default: CARDS_WITH_CUSTOM_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const { tagMapping } = wrapper.vm.cardStackRef; + expect(tagMapping.length).toBe(3); + expect(tagMapping[0][0]).toBe('Neutral'); + expect(tagMapping[1][0]).toBe('Success'); + expect(tagMapping[2][0]).toBe('Failure'); + }); + + test('should apply custom hex colors from tag-configs', async () => { + const tagConfigs = JSON.stringify([ + { name: 'Success', color: '#28a745' }, + { name: 'Failure', color: '#dc3545' }, + ]); + const wrapper = mount(CardStack, { + propsData: { + dataTagConfigs: tagConfigs.replace(/"/g, '"'), + }, + slots: { default: CARDS_WITH_CUSTOM_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const { tagMapping } = wrapper.vm.cardStackRef; + expect(tagMapping[0][1].badgeColor).toBe('#28a745'); + expect(tagMapping[1][1].badgeColor).toBe('#dc3545'); + }); + + test('should convert Bootstrap color names to classes', async () => { + const tagConfigs = JSON.stringify([ + { name: 'Success', color: 'success' }, + { name: 'Failure', color: 'danger' }, + { name: 'Neutral', color: 'warning' }, + ]); + const wrapper = mount(CardStack, { + propsData: { + dataTagConfigs: tagConfigs.replace(/"/g, '"'), + }, + slots: { default: CARDS_WITH_CUSTOM_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const { tagMapping } = wrapper.vm.cardStackRef; + expect(tagMapping[0][1].badgeColor).toBe('bg-success'); + expect(tagMapping[1][1].badgeColor).toBe('bg-danger'); + expect(tagMapping[2][1].badgeColor).toBe('bg-warning text-dark'); + }); + + test('should use default colors for unconfigured tags', async () => { + const tagConfigs = JSON.stringify([{ name: 'Success', color: '#28a745' }]); + const wrapper = mount(CardStack, { + propsData: { + dataTagConfigs: tagConfigs.replace(/"/g, '"'), + }, + slots: { default: CARDS_WITH_CUSTOM_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const { tagMapping } = wrapper.vm.cardStackRef; + // Success should have custom color + expect(tagMapping[0][1].badgeColor).toBe('#28a745'); + // Other tags should have default Bootstrap colors + expect(tagMapping[1][1].badgeColor).toMatch(/^bg-/); + expect(tagMapping[2][1].badgeColor).toMatch(/^bg-/); + }); + + test('should handle invalid tag-configs gracefully', async () => { + const wrapper = mount(CardStack, { + propsData: { + dataTagConfigs: 'invalid-json', + }, + slots: { default: CARDS_WITH_CUSTOM_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Should still render with default colors + const { tagMapping } = wrapper.vm.cardStackRef; + expect(tagMapping.length).toBe(3); + expect(tagMapping[0][1].badgeColor).toMatch(/^bg-/); + }); + + test('isBootstrapColor should correctly identify Bootstrap colors', async () => { + const wrapper = mount(CardStack, { + slots: { default: CARDS_WITH_CUSTOM_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isBootstrapColor('bg-primary')).toBe(true); + expect(wrapper.vm.isBootstrapColor('bg-warning text-dark')).toBe(true); + expect(wrapper.vm.isBootstrapColor('#28a745')).toBe(false); + expect(wrapper.vm.isBootstrapColor('custom-color')).toBe(false); + }); + + test('getTextColor should return correct contrast color', async () => { + const wrapper = mount(CardStack, { + slots: { default: CARDS_WITH_CUSTOM_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Light background should have dark text + expect(wrapper.vm.getTextColor('#ffffff')).toBe('#000'); + expect(wrapper.vm.getTextColor('#f0f0f0')).toBe('#000'); + + // Dark background should have light text + expect(wrapper.vm.getTextColor('#000000')).toBe('#fff'); + expect(wrapper.vm.getTextColor('#333333')).toBe('#fff'); + }); }); diff --git a/packages/vue-components/src/cardstack/Card.vue b/packages/vue-components/src/cardstack/Card.vue index cecc41ef0b..eff45c181b 100644 --- a/packages/vue-components/src/cardstack/Card.vue +++ b/packages/vue-components/src/cardstack/Card.vue @@ -22,7 +22,11 @@ {{ key[0] }} @@ -128,6 +132,34 @@ export default { }, }, methods: { + isBootstrapColor(color) { + // Check if the color is a Bootstrap class + const bootstrapColors = [ + 'bg-primary', + 'bg-secondary', + 'bg-success', + 'bg-danger', + 'bg-warning text-dark', + 'bg-info text-dark', + 'bg-light text-dark', + 'bg-dark', + ]; + return bootstrapColors.some(c => c === color); + }, + getTextColor(backgroundColor) { + // Simple function to determine if text should be light or dark + if (!backgroundColor || backgroundColor.startsWith('bg-')) { + return '#000'; + } + // Parse hex color + const hex = backgroundColor.replace('#', ''); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + // Calculate relative luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? '#000' : '#fff'; + }, }, mounted() { this.cardStack = this.cardStackRef; diff --git a/packages/vue-components/src/cardstack/CardStack.vue b/packages/vue-components/src/cardstack/CardStack.vue index 4fec2fa9e1..42d07d0975 100644 --- a/packages/vue-components/src/cardstack/CardStack.vue +++ b/packages/vue-components/src/cardstack/CardStack.vue @@ -26,7 +26,11 @@ {{ key[0] }}  @@ -72,6 +76,14 @@ export default { type: Boolean, default: false, }, + tagConfigs: { + type: String, + default: '', + }, + dataTagConfigs: { + type: String, + default: '', + }, showSelectAll: { type: [Boolean, String], default: true, @@ -139,6 +151,24 @@ export default { this.showAllTags(); } }, + isBootstrapColor(color) { + // Check if the color is a Bootstrap class + return BADGE_COLOURS.some(c => c === color); + }, + getTextColor(backgroundColor) { + // Simple function to determine if text should be light or dark + if (!backgroundColor || backgroundColor.startsWith('bg-')) { + return '#000'; + } + // Parse hex color + const hex = backgroundColor.replace('#', ''); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + // Calculate relative luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? '#000' : '#fff'; + }, }, data() { return { @@ -152,13 +182,88 @@ export default { searchTerms: [], selectedTags: [], searchData: new Map(), + component: null, // Will be set to the component instance updateTagMapping() { const tags = this.rawTags; const tagMap = new Map(); let index = 0; + // First, parse custom tag configs if provided + let customConfigs = []; + try { + const configSource = this.component?.dataTagConfigs || this.component?.tagConfigs; + if (configSource && configSource !== '') { + // Decode HTML entities (quotes were escaped to prevent SSR warnings) + const decodedConfig = configSource.replace(/"/g, '"'); + // The prop might be double-stringified, so parse once or twice + let parsed = decodedConfig; + // eslint-disable-next-line lodash/prefer-lodash-typecheck + if (typeof parsed === 'string') { + parsed = JSON.parse(parsed); + } + // eslint-disable-next-line lodash/prefer-lodash-typecheck + if (typeof parsed === 'string') { + parsed = JSON.parse(parsed); + } + customConfigs = parsed; + } + } catch (e) { + // If parsing fails, continue with default behavior + // eslint-disable-next-line no-console + console.warn('Failed to parse tag-configs:', e); + } + + // Create a map of custom tag names to their configs + const customConfigMap = new Map(); + customConfigs.forEach((config) => { + customConfigMap.set(config.name, config); + }); + + // Helper function to normalize color value + const normalizeColor = (color) => { + if (!color) return null; + + // If it's a hex color, return as-is + if (color.startsWith('#')) { + return color; + } + + // Check if it's a Bootstrap color name (without bg- prefix) + const bootstrapColorNames = [ + 'primary', 'secondary', 'success', 'danger', + 'warning', 'info', 'light', 'dark', + ]; + const lowerColor = color.toLowerCase(); + + if (bootstrapColorNames.includes(lowerColor)) { + // Add bg- prefix and handle special cases for text color + if (lowerColor === 'warning' || lowerColor === 'info' || lowerColor === 'light') { + return `bg-${lowerColor} text-dark`; + } + return `bg-${lowerColor}`; + } + + // If it already has bg- prefix, assume it's a valid Bootstrap class + if (color.startsWith('bg-')) { + return color; + } + + // Otherwise, treat it as a hex color (for future flexibility) + return color; + }; + + // Process tags in the order specified in customConfigs first + customConfigs.forEach((config) => { + if (tags.includes(config.name)) { + const color = normalizeColor(config.color) || BADGE_COLOURS[index % BADGE_COLOURS.length]; + const tagMapping = { badgeColor: color, children: [], disableTag: false }; + tagMap.set(config.name, tagMapping); + index += 1; + } + }); + + // Then add any remaining tags that weren't in customConfigs tags.forEach((tag) => { - // "tag" -> {badgeColor, children : [child], disableTag: false} if (!tagMap.has(tag)) { const color = BADGE_COLOURS[index % BADGE_COLOURS.length]; const tagMapping = { badgeColor: color, children: [], disableTag: false }; @@ -166,6 +271,7 @@ export default { index += 1; } }); + this.tagMapping = Array.from(tagMap.entries()); }, updateSearchData() { @@ -188,6 +294,10 @@ export default { }, }; }, + created() { + // Set the component reference so updateTagMapping can access props + this.cardStackRef.component = this; + }, mounted() { this.isMounted = true; }, From 7698efd87ad5e7938cf427846fd20066c6315697 Mon Sep 17 00:00:00 2001 From: yihao Date: Thu, 29 Jan 2026 23:08:06 +0800 Subject: [PATCH 02/12] Refactor and abstract code --- packages/core/src/html/MdAttributeRenderer.ts | 17 ++-- packages/core/src/utils/escape.ts | 7 ++ .../vue-components/src/cardstack/Card.vue | 35 ++------ .../src/cardstack/CardStack.vue | 80 ++--------------- packages/vue-components/src/utils/colors.js | 61 +++++++++++++ packages/vue-components/src/utils/utils.js | 89 +++++++++++-------- 6 files changed, 143 insertions(+), 146 deletions(-) create mode 100644 packages/core/src/utils/escape.ts create mode 100644 packages/vue-components/src/utils/colors.js diff --git a/packages/core/src/html/MdAttributeRenderer.ts b/packages/core/src/html/MdAttributeRenderer.ts index cae5fffe85..b54e7d79e4 100644 --- a/packages/core/src/html/MdAttributeRenderer.ts +++ b/packages/core/src/html/MdAttributeRenderer.ts @@ -4,11 +4,14 @@ import type { MarkdownProcessor } from './MarkdownProcessor'; import * as logger from '../utils/logger'; import { createSlotTemplateNode } from './elements'; import { MbNode, NodeOrText, parseHTML } from '../utils/node'; +import { escapeHTML } from '../utils/escape'; const _ = { has, }; +export type tagAttribs = { name: string; color?: string }; + /** * Class that is responsible for rendering markdown-in-attributes */ @@ -30,7 +33,7 @@ export class MdAttributeRenderer { processAttributeWithoutOverride(node: MbNode, attribute: string, isInline: boolean, slotName = attribute): void { const hasAttributeSlot = node.children - && node.children.some(child => getVslotShorthandName(child) === slotName); + && node.children.some(child => getVslotShorthandName(child) === slotName); if (!hasAttributeSlot && _.has(node.attribs, attribute)) { let rendered; @@ -183,20 +186,18 @@ export class MdAttributeRenderer { } const tagsNode = node.children[tagsNodeIndex] as MbNode; - const tagConfigs: Array<{ name: string; color?: string }> = []; + const tagConfigs: Array = []; // Parse each element if (tagsNode.children) { tagsNode.children.forEach((child) => { if (child.type === 'tag' && (child as MbNode).name === 'tag') { const tagNode = child as MbNode; - if (tagNode.attribs && tagNode.attribs.name) { - const config: { name: string; color?: string } = { + if (tagNode.attribs?.name) { + const config: tagAttribs = { name: tagNode.attribs.name, + ...(tagNode.attribs.color && { color: tagNode.attribs.color }), }; - if (tagNode.attribs.color) { - config.color = tagNode.attribs.color; - } tagConfigs.push(config); } } @@ -209,7 +210,7 @@ export class MdAttributeRenderer { if (tagConfigs.length > 0) { const jsonString = JSON.stringify(tagConfigs); // Replace double quotes with HTML entities to avoid SSR warnings - const escapedJson = jsonString.replace(/"/g, '"'); + const escapedJson = escapeHTML(jsonString); node.attribs['data-tag-configs'] = escapedJson; } diff --git a/packages/core/src/utils/escape.ts b/packages/core/src/utils/escape.ts new file mode 100644 index 0000000000..18a06f9416 --- /dev/null +++ b/packages/core/src/utils/escape.ts @@ -0,0 +1,7 @@ +export function escapeHTML(htmlStr: string) { + return htmlStr + .replace(/&(?!\w+;)/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/packages/vue-components/src/cardstack/Card.vue b/packages/vue-components/src/cardstack/Card.vue index eff45c181b..9be1df428f 100644 --- a/packages/vue-components/src/cardstack/Card.vue +++ b/packages/vue-components/src/cardstack/Card.vue @@ -38,6 +38,7 @@