Add Sort by Functionality to Tag Column
Goal:
To introduce a sorting functionality to the tag column, allowing users to sort tools by different criteria. Default criteria include:
- Frequency of tag usage, toggle from most to least and least to most
- Alphabetic order, toggle from A to Z and Z to A
To make the TagColumn and TagChip components reusable components, and be able to implement them with minimal changes to the Prompts collection rendered in the
site/src/pages/thread/[magazine].astro
page.Context
Our Existing Implementation
The tag column is currently implemented as a simple list of tags, with no sorting functionality. Right now, I think they appear in the order of frequency of tag usage, from most to least.
Our Current Render Pipeline:
site/src/pages/toolkit.astro
site/src/layouts/ToolkitLayout.astro
site/src/components/tool-components/TagColumn.astro
4.site/src/components/tool-components/TagChip.astro
site/src/components/tool-components/CardGrid.astro
6.site/src/components/tool-components/ToolCard.astro
7.site/src/components/tool-components/BareToolCard.astro
Data Flow and Sorting Logic (Technical Specification)
Data Flow Overview
- toolkit.astro: Loads all tools and their tags from content collections or API.
- ToolkitLayout.astro: Receives all tool data as props, extracts tags, and passes them to
TagColumn
.- Does not use hard "Type Safety" per our constraints, as the Content Team does not always maintain perfect metadata.
- TagColumn.astro: Receives an array of tags (with frequency counts) as a prop, displays them, and implements sorting logic and UI.
- TagChip.astro: Receives a single tag (and possibly its frequency/count) as a prop, renders it as a clickable or display element.
Sorting is handled inside
TagColumn.astro
, so the component is reusable and encapsulates its own sorting logic and UI state.Mermaid Diagram
flowchart TD
A[toolkit.astro] -->|loads tools & tags| B[ToolkitLayout.astro]
B -->|passes tags prop| C[TagColumn.astro]
C -->|renders sorted tags| D[TagChip.astro]
C -- sorts tags by user selection --> D
Implementation Approach
For this purely client-side sorting functionality, we'll use Astro's standard client-side scripting capabilities. While Astro Actions are powerful for server operations, our tag sorting is a purely client-side concern that doesn't require server processing after the initial page load.
Example Code Snippets
1. toolkit.astro (top-level page)
astro
---
import ToolkitLayout from '../layouts/ToolkitLayout.astro';
import { getAllTools } from '~/lib/data';
const tools = await getAllTools();
const tags = extractTagsWithCounts(tools); // [{ tag: 'AI', count: 12 }, ...]
---
<ToolkitLayout tools={tools} tags={tags} />
2. ToolkitLayout.astro (layout)
astro
---
const { tools, tags } = Astro.props;
import TagColumn from '../components/tool-components/TagColumn.astro';
---
<div class="toolkit-layout">
<TagColumn tags={tags} />
<!-- ...other layout components... -->
</div>
3. TagColumn.astro (component)
astro
---
const { tags } = Astro.props;
import TagChip from './TagChip.astro';
// Initial values for client-side sorting
let sortType = 'frequency-desc';
// Function to sort tags based on sort type
function getSortedTags(tags, sortType) {
if (sortType === 'frequency-desc') return [...tags].sort((a, b) => b.count - a.count);
if (sortType === 'frequency-asc') return [...tags].sort((a, b) => a.count - b.count);
if (sortType === 'alpha-asc') return [...tags].sort((a, b) => a.tag.localeCompare(b.tag));
if (sortType === 'alpha-desc') return [...tags].sort((a, b) => b.tag.localeCompare(a.tag));
return tags; // Default fallback
}
// Get initial sorted tags for initial render
const initialSortedTags = getSortedTags(tags, sortType);
---
<div class="tag-column">
<!-- Sort controls -->
<div class="sort-controls">
<button id="sort-alpha" class="sort-button" data-sort-type="alpha-asc" aria-label="Sort alphabetically">
<span class="icon">A-Z</span>
</button>
<button id="sort-freq" class="sort-button active" data-sort-type="frequency-desc" aria-label="Sort by frequency">
<span class="icon">#</span>
</button>
</div>
<!-- Tag list (initial render) -->
<div id="tag-list" class="tag-list" data-tags={JSON.stringify(tags)}>
{initialSortedTags.map(tag => (
<TagChip
tag={tag.tag}
count={tag.count}
id={`tag-${tag.tag}`}
/>
))}
</div>
</div>
<!-- Client-side interactivity with vanilla JS -->
<script>
// Get DOM elements
const sortAlphaBtn = document.getElementById('sort-alpha');
const sortFreqBtn = document.getElementById('sort-freq');
const tagList = document.getElementById('tag-list');
// Get tag data from the data attribute
const tags = JSON.parse(tagList.dataset.tags);
// Current sort state
let currentSortType = 'frequency-desc';
// Sort function (same as in the component front matter)
function getSortedTags(tags, sortType) {
if (sortType === 'frequency-desc') return [...tags].sort((a, b) => b.count - a.count);
if (sortType === 'frequency-asc') return [...tags].sort((a, b) => a.count - b.count);
if (sortType === 'alpha-asc') return [...tags].sort((a, b) => a.tag.localeCompare(b.tag));
if (sortType === 'alpha-desc') return [...tags].sort((a, b) => b.tag.localeCompare(a.tag));
return tags;
}
// Render function to update the tag list
function renderTags() {
const sortedTags = getSortedTags(tags, currentSortType);
// Clear current list
tagList.innerHTML = '';
// Add sorted tags
sortedTags.forEach(tag => {
// Create a new tag chip element
const chip = document.createElement('div');
chip.id = `tag-${tag.tag}`;
chip.className = 'tag-chip';
// Convert train-case to Normal Case for display
const normalCase = tag.tag
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
chip.innerHTML = `
${normalCase}
<span class="tag-count">${tag.count}</span>
`;
// Add click event listener
chip.addEventListener('click', () => {
window.location.href = `/toolkit/${encodeURIComponent(tag.tag)}`;
});
tagList.appendChild(chip);
});
}
// Event handlers for sort buttons
sortAlphaBtn.addEventListener('click', () => {
// Toggle between alpha-asc and alpha-desc
currentSortType = currentSortType === 'alpha-asc' ? 'alpha-desc' : 'alpha-asc';
// Update active button state
sortAlphaBtn.classList.add('active');
sortFreqBtn.classList.remove('active');
// Update button data attribute
sortAlphaBtn.dataset.sortType = currentSortType;
// Re-render tags
renderTags();
});
sortFreqBtn.addEventListener('click', () => {
// Toggle between frequency-desc and frequency-asc
currentSortType = currentSortType === 'frequency-desc' ? 'frequency-asc' : 'frequency-desc';
// Update active button state
sortFreqBtn.classList.add('active');
sortAlphaBtn.classList.remove('active');
// Update button data attribute
sortFreqBtn.dataset.sortType = currentSortType;
// Re-render tags
renderTags();
});
</script>
4. TagChip.astro (component)
astro
---
// Define props interface
interface Props {
tag: string;
count?: number;
includeCount?: boolean;
id?: string;
}
// Destructure props
const { tag, count, includeCount = false, id } = Astro.props;
// Convert train-case to Normal Case for display
function trainCaseToNormalCase(tag) {
return tag
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
const normalCase = trainCaseToNormalCase(tag);
---
<button
class="tag-chip"
id={id}
data-tag={tag}
aria-label={`Click to see content tagged with ${normalCase}`}
title={`Click to see content tagged with ${normalCase}`}
>
{normalCase}
{includeCount && count !== undefined && <span class="tag-count">{count}</span>}
</button>
<script>
// Get all tag chips
const tagChips = document.querySelectorAll('.tag-chip');
// Add click event listeners
tagChips.forEach(chip => {
chip.addEventListener('click', () => {
const tag = chip.dataset.tag;
// Navigate to filtered view or trigger filter action
window.location.href = `/toolkit/${encodeURIComponent(tag)}`;
});
});
</script>
UI/UX Requirements
Sort Controls
- Placement: At the top of the tag column, above the list of tags.
- Appearance: Simple, icon-based buttons that clearly indicate their purpose.
- Functionality:
- Clicking the "A-Z" button toggles between alphabetical ascending (A-Z) and descending (Z-A).
- Clicking the "#" button toggles between frequency descending (most used first) and ascending (least used first).
- The currently active sort criterion should be visually indicated.
Example structure:
astro
<div class="tag-sort-toggle">
<button class="sort-alpha" id="sort-alpha">
<Icon name="tabler:list-letters" />
<!-- Optionally indicate direction/state -->
</button>
<button class="sort-frequency" id="sort-freq">
<Icon name="tabler:sort-descending-numbers" />
<!-- Optionally indicate direction/state -->
</button>
</div>
Tag Chip Accessibility
- Each TagChip should have appropriate ARIA attributes and title for accessibility.
- The text displayed should be in Normal Case for readability, even if the tag is stored in train-case.
Usage Example in TagChip (Astro)
astro
const normalCase = trainCaseToNormalCase(tag);
<button
class="tag-chip"
aria-label={`Click to see content tagged with ${normalCase}`}
title={`Click to see content tagged with ${normalCase}`}
>
{normalCase}
{count && <span class="tag-count">{count}</span>}
</button>
- This ensures screen readers and tooltips provide a human-friendly, descriptive label for each tag.
Mobile Responsiveness
- On mobile devices, the tag column should be collapsible to save space.
- Default (Mobile): TagColumn is collapsed, visible as a slim tab or icon on the left.
- User taps tab/icon: TagColumn expands, showing all tags and sorting controls.
- User selects a tag: TagColumn immediately collapses, revealing filtered results in the main content area.
- User can re-expand TagColumn at any time to change selection.
- The collapse/expand state should be managed in client-side JavaScript using a
<script>
block with event listeners.
Reusability & Abstraction Guidelines
- TagColumn must accept a prop named
collectionExtractedTagArray
, which is an array of tag objects from any collection or data source.- Example:js
[ { tag: "AI", count: 12 }, { tag: "Productivity", count: 5 } ]
- The source of the tag array (content collection, API, static data, etc.) is not the concern of TagColumn.
- The component should not assume anything about the parent context except that it receives this array.
- TagChip should be presentational by default, but may optionally accept additional props for:
- Its current sort state (e.g., through data attributes)
- Its index/order in the current sort (e.g., through data attributes)
- Any other metadata useful for accessibility or UI (e.g.,
aria-label
,title
, etc.) - These props should be optional and documented, so TagChip can be used in both simple and advanced scenarios.
- State Management:
- Use Astro's client-side scripting with standard
<script>
blocks for interactive functionality. - TagColumn should manage its own sort state with client-side JavaScript.
- Store the current sort state in a variable and use it to update the UI when the user interacts with the sort controls.
- Use DOM manipulation to update the tag list when the sort order changes.
- Example Prop Interface:ts
// For TagColumn type TagColumnProps = { collectionExtractedTagArray: Array<{ tag: string; count?: number; [key: string]: any }>; // ...other config options } // For TagChip type TagChipProps = { tag: string; count?: number; id?: string; // ...other UI/accessibility props }
- Flexibility:
- TagColumn and TagChip should not be tightly coupled to any specific data source or sort logic.
- Document the expected shape of the tag array and optional props for maximum developer clarity.
This abstraction ensures TagColumn and TagChip can be reused across different collections and contexts without modification.
Selected Tags Feature Implementation
To enhance the tag filtering experience, we need to implement a feature where selected tags "pop up" to the top of the TagColumn, and users can select multiple tags simultaneously.
1. Approach Overview
- Track Selected Tags: Add a mechanism to track which tags are currently selected
- Modify Sort Logic: Update the sorting logic to prioritize selected tags
- Visual Indicators: Enhance the UI to clearly show selected tags
- Multiple Selection: Enable multiple tag selection with appropriate URL handling
- Card Sorting: Sort tool cards based on how many selected tags they match
2. Implementation Details
The implementation will involve:
- Update the TagColumn Component:
- Add functionality to track selected tags in client-side state
- Modify the sort function to prioritize selected tags
- Update the rendering logic to visually distinguish selected tags
- Implement "OR" logic for filtering (show cards matching ANY selected tag)
- Sort cards by match count (cards matching more tags appear first)
- Enhance the TagChip Component:
- Add a "selected" state that can be toggled
- Update the styling to show a clear visual difference for selected tags
- URL and Navigation Handling:
- Modify the URL structure to support multiple tag parameters
- Update the click handlers to toggle tags rather than navigate directly
- Preserve state in the URL for bookmarking and sharing
3. Code Implementation Examples
Client-side Tag Selection State:
javascript
// In TagColumn.astro client script
let selectedTags = [];
// Check URL for already selected tags
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('tags')) {
selectedTags = urlParams.get('tags').split(',');
}
// Function to toggle tag selection
function toggleTagSelection(tag) {
if (selectedTags.includes(tag)) {
selectedTags = selectedTags.filter(t => t !== tag);
} else {
selectedTags.push(tag);
}
// Update URL
const newUrl = new URL(window.location);
if (selectedTags.length > 0) {
newUrl.searchParams.set('tags', selectedTags.join(','));
} else {
newUrl.searchParams.delete('tags');
}
history.pushState({}, '', newUrl);
// Re-render tags with new selection state
renderTags();
// Trigger content filtering
filterContent(selectedTags);
}
Updated Sorting Logic:
javascript
// Modified sort function to prioritize selected tags
function getSortedTags(tags, sortType, selectedTags) {
// First sort by selection status
const sortedTags = [...tags].sort((a, b) => {
const aSelected = selectedTags.includes(a.tag);
const bSelected = selectedTags.includes(b.tag);
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
// If both have same selection status, sort by the chosen criteria
if (sortType === 'frequency-desc') return b.count - a.count;
if (sortType === 'frequency-asc') return a.count - b.count;
if (sortType === 'alpha-asc') return a.tag.localeCompare(b.tag);
if (sortType === 'alpha-desc') return b.tag.localeCompare(a.tag);
return 0;
});
return sortedTags;
}
Updated TagChip Component:
astro
---
// In TagChip.astro
interface Props {
tag: string;
count?: number;
selected?: boolean;
onToggle?: () => void;
}
const { tag, count, selected = false } = Astro.props;
---
<div
class={`tool-tag ${selected ? 'selected' : ''}`}
data-tag={tag}
>
<p>{tag}</p>
{count !== undefined && <span class="count">{count}</span>}
</div>
<script>
// Client-side event handling
document.querySelectorAll('.tool-tag').forEach(tag => {
tag.addEventListener('click', () => {
// Dispatch a custom event that TagColumn will listen for
const tagValue = tag.dataset.tag;
const event = new CustomEvent('tagToggled', {
detail: { tag: tagValue },
bubbles: true
});
tag.dispatchEvent(event);
});
});
</script>
Event Listening in TagColumn:
javascript
// In TagColumn.astro client script
document.addEventListener('tagToggled', (event) => {
const { tag } = event.detail;
toggleTagSelection(tag);
});
Content Filtering Function:
javascript
// Function to filter content based on selected tags
function filterContent(selectedTags) {
// Get all tool cards
const toolCards = document.querySelectorAll('.tool-card');
// If no tags selected, show all cards
if (selectedTags.length === 0) {
toolCards.forEach(card => {
card.style.display = '';
});
return;
}
// Create an array to track cards and their match counts
const cardMatches = [];
// Filter cards based on selected tags
toolCards.forEach(card => {
// Get the card's tags
const cardTagsStr = card.dataset.tags;
if (!cardTagsStr) return;
const cardTags = JSON.parse(cardTagsStr);
// Count how many selected tags match this card's tags
const matchCount = selectedTags.filter(tag => cardTags.includes(tag)).length;
// If the card has at least one matching tag, add it to our array with its match count
if (matchCount > 0) {
cardMatches.push({
card: card,
matchCount
});
} else {
// Hide cards with no matches
card.style.display = 'none';
}
});
// Sort cards by match count (descending)
cardMatches.sort((a, b) => b.matchCount - a.matchCount);
// Get the parent container of the cards
const cardContainer = toolCards[0]?.parentElement;
if (!cardContainer) return;
// Remove all cards from the DOM
toolCards.forEach(card => card.remove());
// Add cards back in the new sorted order
cardMatches.forEach(({card, matchCount}) => {
// Show the card
card.style.display = '';
// Add a data attribute showing the match count (useful for debugging)
card.setAttribute('data-match-count', matchCount.toString());
// Add the card back to the container
cardContainer.appendChild(card);
});
}
Dynamic Rendering based on selected "TagChip" as a filter.
From
site/src/layouts/ToolkitLayout.astro
, javascript
// Filter entries by tag if filterTag is provided
const filteredEntries = filterTag
? toolEntries.filter(entry => entry.data.tags?.includes(filterTag))
: toolEntries;
// Get all unique tags
const allTags = Array.from(
new Set(
toolEntries.flatMap(entry => entry.data.tags || [])
)
).sort();
// Map entries to tools format with filePath
const toolsWithFilePath = filteredEntries.map(entry => ({
...entry.data,
id: entry.id,
filePath: `../content/tooling/${entry.id}`,
}));
html
<Layout title={title} description={description}>
<main>
<div class="toolkit-container">
<div class="sidebar">
<TagColumn allRenderedTags={allTags} tools={toolEntries.map(entry => entry.data)} />
</div>
<div class="tools-container">
<CardGrid
tools={toolsWithFilePath}
gap={gap}
minCardWidth={minCardWidth}
/>
</div>
</div>
</main>
</Layout>
Summary
This implementation provides the following key benefits:
- Enhanced Tag Selection:
- Selected tags appear at the top of the tag list for easy visibility
- Multiple tags can be selected simultaneously
- Visual highlighting clearly indicates which tags are selected
- Improved Content Filtering:
- Uses "OR" logic to show cards matching ANY selected tag
- Cards are sorted by relevance (number of matching tags)
- Cards with more matching tags appear at the top
- State Preservation:
- The URL reflects the current selection state
- Users can bookmark or share specific tag combinations
- Selection state is preserved during page navigation
- Responsive User Experience:
- All filtering and sorting happens client-side without page reloads
- Tag selection provides immediate visual feedback
- Mobile-friendly design with collapsible tag column
This implementation maintains the existing sort functionality while adding powerful multi-tag selection and relevance-based content ordering.