Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3b6bbf4
Add watcher to populate selectedTags on first load
MoshiMoshiMochi Jan 20, 2026
4502021
Add toggle all tag indicator
MoshiMoshiMochi Jan 20, 2026
79cbd4c
Add test cases
MoshiMoshiMochi Jan 20, 2026
c9fde7b
Remove old redundant code comments
MoshiMoshiMochi Jan 20, 2026
c8ec29c
Fix disable documentation error
MoshiMoshiMochi Jan 22, 2026
23b0cbb
Merge remote-tracking branch 'upstream' into card-stack-default-tick-all
MoshiMoshiMochi Jan 22, 2026
8b9d32f
Add optional showSelectAll flag to disable selectAll tag
MoshiMoshiMochi Jan 23, 2026
4753f31
Add testcases and minor edits to toggle logic
MoshiMoshiMochi Jan 24, 2026
12fd8f6
Merge remote-tracking branch 'upstream' into card-stack-default-tick-all
MoshiMoshiMochi Jan 24, 2026
bd65fb8
Update user guide
MoshiMoshiMochi Jan 24, 2026
fbaefbf
Rebuild core-web package
MoshiMoshiMochi Jan 24, 2026
a91c8c0
Add more robust check for null or undefined
MoshiMoshiMochi Jan 24, 2026
7be747f
Update showSelectAll to accept string too
MoshiMoshiMochi Jan 24, 2026
b4adcb1
Undid core-web changes and updated package-lock.json
MoshiMoshiMochi Jan 24, 2026
96c9049
Add select all styling and minimum tags requirement
MoshiMoshiMochi Jan 25, 2026
b0754e0
Add & fix test cases
MoshiMoshiMochi Jan 25, 2026
56f105a
Fix indentation issue
MoshiMoshiMochi Jan 25, 2026
b5021c9
Update user guide to reflect latest changes
MoshiMoshiMochi Jan 25, 2026
ed7b12e
Revert package-lock.json changes
MoshiMoshiMochi Jan 25, 2026
6abd147
Sync package-lock with package.json
MoshiMoshiMochi Jan 25, 2026
2cb9264
Merge remote-tracking branch 'upstream' into card-stack-default-tick-all
MoshiMoshiMochi Jan 25, 2026
3798be0
Sync package-lock.json to upstream
MoshiMoshiMochi Jan 25, 2026
4cdfaab
Add test cases for core package
MoshiMoshiMochi Jan 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/userGuide/syntax/cardstacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,15 @@ Name | Type | Default | Description
--- | --- | --- | ---
blocks | `String` | `2` | Number of `card` columns per row.<br> Supports: `1`, `2`, `3`, `4`, `6`
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)

`card`:
Name | Type | Default | Description
--- | --- | --- | ---
tag | `String` | `null` | Tags of each card component.<br>Each unique tag should be seperated by a `,`.<br> Tags are added to the search field.
header | `String` | `null` | Header of each card component.<br> Supports the use of inline markdown elements.
keywords | `String` | `null` | Keywords of each card component.<br>Each unique keyword should be seperated by a `,`.<br> Keywords are added to the search field.
disable | `Boolean` | `false` | Disable card. <br> This removes visibility of the card and makes it unsearchable.
disabled | `Boolean` | `false` | Disable card. <br> This removes visibility of the card and makes it unsearchable.



Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/html/MdAttributeRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import has from 'lodash/has';
import isNil from 'lodash/isNil';
import { getVslotShorthandName } from './vueSlotSyntaxProcessor';
import type { MarkdownProcessor } from './MarkdownProcessor';
import * as logger from '../utils/logger';
Expand All @@ -7,6 +8,7 @@ import { MbNode, NodeOrText, parseHTML } from '../utils/node';

const _ = {
has,
isNil,
};

/**
Expand Down Expand Up @@ -160,6 +162,26 @@ export class MdAttributeRenderer {
this.processSlotAttribute(node, 'header', false);
}

// eslint-disable-next-line class-methods-use-this
processCardStackAttributes(node: MbNode) {
if (!node.children || !node.attribs) {
return;
}

const showSelectAllRaw = node.attribs['show-select-all'];
// Handles the 'show-select-all' attribute on the cardstack itself
if (!_.isNil(showSelectAllRaw)) {
// Check if they have specified 'false' explicitly
const showSelectAll = showSelectAllRaw.toLowerCase();
if (showSelectAll === 'false') {
node.attribs['show-select-all'] = 'false';
} else {
// Default option or if user specifies any other value it is treated as true.
node.attribs['show-select-all'] = 'true';
}
}
}

/*
* Card Stack
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/html/NodeProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ export class NodeProcessor {
case 'tooltip':
this.mdAttributeRenderer.processTooltip(node);
break;
case 'cardstack':
this.mdAttributeRenderer.processCardStackAttributes(node);
break;
case 'card':
this.mdAttributeRenderer.processCardAttributes(node);
break;
Expand Down
19 changes: 19 additions & 0 deletions packages/core/test/unit/html/NodeProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,22 @@ test('slot nodes which have tag names other than "template" are shifted one leve

expect(cheerio.html(testNode)).toEqual(expected);
});

test('processNode standardizes cardstack show-select-all attribute', () => {
const nodeProcessor = getNewDefaultNodeProcessor();

const templateFalse = '<cardstack show-select-all="fAlSe"></cardstack>';
const nodeFalse = parseHTML(templateFalse)[0] as MbNode;
nodeProcessor.processNode(nodeFalse, new Context(path.resolve(''), [], {}, {}));
expect(nodeFalse.attribs['show-select-all']).toBe('false');

const templateTrue = '<cardstack show-select-all="yes"></cardstack>';
const nodeTrue = parseHTML(templateTrue)[0] as MbNode;
nodeProcessor.processNode(nodeTrue, new Context(path.resolve(''), [], {}, {}));
expect(nodeTrue.attribs['show-select-all']).toBe('true');

const templateNone = '<cardstack></cardstack>';
const nodeNone = parseHTML(templateNone)[0] as MbNode;
nodeProcessor.processNode(nodeNone, new Context(path.resolve(''), [], {}, {}));
expect(nodeNone.attribs['show-select-all']).toBeUndefined();
});
138 changes: 138 additions & 0 deletions packages/vue-components/src/__tests__/CardStack.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const DEFAULT_GLOBAL_MOUNT_OPTIONS = {
stubs: DEFAULT_STUBS,
};

const CARDS_FOR_SELECT_ALL = `
<card header="Card 1" tag="Tag1"></card>
<card header="Card 2" tag="Tag2"></card>
<card header="Card 3" tag="Tag3"></card>
<card header="Card 4" tag="Tag4"></card>
`;

const CARDS_CUSTOMISATION = `
<card header="Normal Body" tag="Normal" keywords="Body" disabled>
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor
Expand Down Expand Up @@ -91,4 +98,135 @@ describe('CardStack', () => {
await wrapper.vm.$nextTick();
expect(wrapper.element).toMatchSnapshot();
});

test('should have all tags checked by default on load', async () => {
const wrapper = mount(CardStack, {
slots: { default: CARDS_CUSTOMISATION },
global: DEFAULT_GLOBAL_MOUNT_OPTIONS,
});
await wrapper.vm.$nextTick();
const allTags = wrapper.vm.cardStackRef.tagMapping.map(key => key[0]);
expect(wrapper.vm.selectedTags).toEqual(expect.arrayContaining(allTags));
expect(wrapper.vm.allSelected).toBe(true);
});

test('toggleAllTags should unselect everything and then select everything', async () => {
const wrapper = mount(CardStack, {
slots: { default: CARDS_FOR_SELECT_ALL },
global: DEFAULT_GLOBAL_MOUNT_OPTIONS,
});
await wrapper.vm.$nextTick();

// selected all initially
expect(wrapper.vm.allSelected).toBe(true);

// deselect everything
const selectAllBadge = wrapper.find('.select-all-toggle');
await selectAllBadge.trigger('click');
expect(wrapper.vm.selectedTags.length).toBe(0);
expect(wrapper.vm.allSelected).toBe(false);

// all cards should be hidden
const cards = wrapper.findAllComponents(Card);
cards.forEach((card) => {
if (card.props('tag') === 'Short') {
expect(card.vm.disableTag).toBe(true);
}
});

// select all again -> everything should be selected back
await selectAllBadge.trigger('click');
expect(wrapper.vm.allSelected).toBe(true);
expect(wrapper.vm.selectedTags.length).toBeGreaterThan(0);
});

test('Select All checkbox should sync with individual tag clicks', async () => {
const wrapper = mount(CardStack, {
slots: { default: CARDS_FOR_SELECT_ALL },
global: DEFAULT_GLOBAL_MOUNT_OPTIONS,
});
await wrapper.vm.$nextTick();

// uncheck first tag
const firstTagBadge = wrapper.findAll('.tag-badge').at(1);
await firstTagBadge.trigger('click');

// select all should no longer be checked
expect(wrapper.vm.allSelected).toBe(false);
const selectAllIndicator = wrapper.find('.select-all-toggle .tag-indicator');
expect(selectAllIndicator.text()).not.toContain('✓');

// Check first tag -> select all should be checked again
await firstTagBadge.trigger('click');
expect(wrapper.vm.allSelected).toBe(true);
});

test('should show Select All badge by default', async () => {
const wrapper = mount(CardStack, {
slots: { default: CARDS_FOR_SELECT_ALL },
global: DEFAULT_GLOBAL_MOUNT_OPTIONS,
});
await wrapper.vm.$nextTick();

// The first badge with .bg-dark is the "Select All" badge
const selectAllBadge = wrapper.find('.select-all-toggle');
expect(selectAllBadge.exists()).toBe(true);
expect(selectAllBadge.text()).toContain('Select All');
});

test('should hide Select All badge when showSelectAll is false', async () => {
const wrapper = mount(CardStack, {
propsData: {
showSelectAll: false, // Testing boolean false
},
slots: { default: CARDS_FOR_SELECT_ALL },
global: DEFAULT_GLOBAL_MOUNT_OPTIONS,
});
await wrapper.vm.$nextTick();

const selectAllBadge = wrapper.find('.select-all-toggle');
expect(selectAllBadge.exists()).toBe(false);
});

test('should hide Select All badge when showSelectAll is "false" string (case-insensitive)', async () => {
// This simulates the parser passing show-select-all="fAlse"
const wrapper = mount(CardStack, {
propsData: {
searchable: true,
showSelectAll: 'fAlse',
},
slots: { default: CARDS_FOR_SELECT_ALL },
global: DEFAULT_GLOBAL_MOUNT_OPTIONS,
});
await wrapper.vm.$nextTick();

const selectAllBadge = wrapper.find('.select-all-toggle');
expect(selectAllBadge.exists()).toBe(false);
});

test('should show Select All badge when showSelectAll is "true" string', async () => {
const wrapper = mount(CardStack, {
propsData: {
searchable: true,
showSelectAll: 'true',
},
slots: { default: CARDS_FOR_SELECT_ALL },
global: DEFAULT_GLOBAL_MOUNT_OPTIONS,
});
await wrapper.vm.$nextTick();

const selectAllBadge = wrapper.find('.select-all-toggle');
expect(selectAllBadge.exists()).toBe(true);
});

test('should hide Select All badge when below threshold (<=3 tags)', async () => {
const wrapper = mount(CardStack, {
slots: { default: CARDS_CUSTOMISATION }, // Only 2 tags
global: DEFAULT_GLOBAL_MOUNT_OPTIONS,
});
await wrapper.vm.$nextTick();

const selectAllBadge = wrapper.find('.select-all-toggle');
expect(selectAllBadge.exists()).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ exports[`CardStack markdown in header, content 1`] = `
type="text"
/>
</span>
<!--v-if-->


</div>
Expand Down Expand Up @@ -96,6 +97,7 @@ exports[`CardStack should not hide cards when no filter is provided 1`] = `
type="text"
/>
</span>
<!--v-if-->

<span
class="badge bg-primary tag-badge"
Expand All @@ -105,7 +107,7 @@ exports[`CardStack should not hide cards when no filter is provided 1`] = `
class="badge bg-light text-dark tag-indicator"
>
<span>
   
</span>
</span>
</span>
Expand Down Expand Up @@ -218,6 +220,7 @@ exports[`CardStack should not hide cards when no filter is provided 2`] = `
type="text"
/>
</span>
<!--v-if-->


</div>
Expand Down
62 changes: 59 additions & 3 deletions packages/vue-components/src/cardstack/CardStack.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@
/>
</template>
</span>
<span
v-if="shouldShowSelectAll"
class="badge tag-badge select-all-toggle"
@click="toggleAllTags"
>
<span class="badge bg-light text-dark tag-indicator">
<span v-if="allSelected">✓</span>
<span v-else>&nbsp;&nbsp;&nbsp;</span>
</span>
Select All
</span>
<span
v-for="(key, index) in cardStackRef.tagMapping"
:key="index"
Expand All @@ -34,6 +45,8 @@
</template>

<script>
const MIN_TAGS_FOR_SELECT_ALL = 3;

const BADGE_COLOURS = [
'bg-primary',
'bg-secondary',
Expand All @@ -59,8 +72,31 @@ export default {
type: Boolean,
default: false,
},
showSelectAll: {
type: [Boolean, String],
default: true,
},
},
computed: {
allSelected() {
return this.selectedTags.length === this.cardStackRef.tagMapping.length;
},
shouldShowSelectAll() {
const isEnabled = this.showSelectAll === 'true' || this.showSelectAll === true;
const hasEnoughTags = this.cardStackRef.tagMapping.length > MIN_TAGS_FOR_SELECT_ALL;
return isEnabled && hasEnoughTags;
},
},
watch: {
'cardStackRef.tagMapping': {
handler(newMapping) {
// Initialise the selectedTags with all tag names when loading for the first time
if (this.selectedTags.length === 0 && newMapping.length > 0) {
this.selectedTags = newMapping.map(key => key[0]);
}
},
immediate: true,
},
},
provide() {
return {
Expand Down Expand Up @@ -100,7 +136,7 @@ export default {
}

if (this.selectedTags.length === 0) {
this.showAllTags();
this.showAllTags(false);
} else {
this.cardStackRef.children.forEach((child) => {
if (child.$props.disabled) return;
Expand All @@ -111,16 +147,26 @@ export default {
});
}
},
showAllTags() {
showAllTags(showTag) {
this.cardStackRef.children.forEach((child) => {
if (child.$props.disabled) return;

child.$data.disableTag = false;
child.$data.disableTag = !showTag;
});
},
computeShowTag(tagName) {
return this.selectedTags.includes(tagName);
},
toggleAllTags() {
const allTags = this.cardStackRef.tagMapping.map(key => key[0]);
if (this.selectedTags.length === allTags.length) {
this.selectedTags = [];
this.showAllTags(false);
} else {
this.selectedTags = allTags;
this.showAllTags(true);
}
},
},
data() {
return {
Expand Down Expand Up @@ -245,4 +291,14 @@ export default {
width: 18px;
height: 100%;
}

.badge.tag-badge.select-all-toggle {
background: transparent !important;
color: #1e1e1e !important;
font-weight: 400;
}

.select-all-toggle .tag-indicator {
margin-right: 1.5px;
}
</style>
Loading