Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2c5e805
This simplifies the radio selection logic by leveraging Svelte's buil…
Ibrahim-Haizel Feb 13, 2025
a0be61b
Add new input property for selected value
Ibrahim-Haizel Feb 13, 2025
6a5efe8
Make selectedValue prop bindable
Ibrahim-Haizel Feb 14, 2025
23ce406
Add bindable value example to Radios component documentation
Ibrahim-Haizel Feb 14, 2025
cd71a9b
made checkbox selectectedValues a bindable prop and added example to …
Ibrahim-Haizel Feb 14, 2025
c5bfa8e
Add selectedValue and selectedValues prop to Radios and Checkbox comp…
Ibrahim-Haizel Feb 17, 2025
9116d22
added initial breadcrumbs component
Ibrahim-Haizel Mar 7, 2025
3336521
Add BreadcrumbsWrapper to layout for site navigation to test breadcru…
Ibrahim-Haizel Mar 7, 2025
2ebb74a
Enhance Breadcrumbs component with automatic route-based generation a…
Ibrahim-Haizel Mar 7, 2025
dec2793
Added initial documentation page for breadcrumbs component
Ibrahim-Haizel Mar 7, 2025
3c8faa8
made containers smaller
Ibrahim-Haizel Mar 7, 2025
dd8261c
Refactor Breadcrumbs component state management and rendering
Ibrahim-Haizel Mar 7, 2025
6f4c34a
Refactor Breadcrumbs component to simplify CSS class management direc…
Ibrahim-Haizel Mar 7, 2025
b2c91c2
Change default inverse prop value to false in documentation component…
Ibrahim-Haizel Mar 7, 2025
e76beee
Update Breadcrumbs content input type to JS
Ibrahim-Haizel Mar 10, 2025
8780222
updated display option defaults
Ibrahim-Haizel Mar 10, 2025
2da4580
Update Breadcrumbs documentation example with govuk blue background c…
Ibrahim-Haizel Mar 10, 2025
be53d0c
Merge branch 'main' into breadcrumbs-component
Ibrahim-Haizel Mar 10, 2025
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
198 changes: 198 additions & 0 deletions src/lib/components/ui/Breadcrumbs.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";

/**
* Breadcrumbs component
*
* Use this component to display a navigation path to the current page.
*
* Features:
* - Automatically generates breadcrumbs based on the current route
* - Set `collapseOnMobile` to true to show only the first and last items on mobile devices
* - Set `inverse` to true to show white links on dark backgrounds (ensure contrast ratio of 4.5:1)
* - Optionally provide custom items array to override automatic breadcrumb generation
*/

// Define the BreadcrumbItem type
export type BreadcrumbItem = {
text: string;
href: string;
};

// Component props
let {
items = undefined,
collapseOnMobile = false,
inverse = false,
ariaLabel = "Breadcrumb",
} = $props<{
items?: BreadcrumbItem[];
collapseOnMobile?: boolean;
inverse?: boolean;
ariaLabel?: string;
}>();

// Load all page modules for route detection
const routeModules = $state<Record<string, any>>({});

onMount(async () => {
try {
// Use Vite's glob import to get all page components
const modules = import.meta.glob("/src/routes/**/+page.svelte", {
eager: true,
});
Object.assign(routeModules, modules);
} catch (error) {
console.warn("Failed to load route modules:", error);
}
});

// Add support detection
let isSupported = $state(false);
onMount(() => {
isSupported =
document.body?.classList.contains("govuk-frontend-supported") ?? false;
});

// State variable to hold the current breadcrumb items
let breadcrumbItems = $state<BreadcrumbItem[]>([]);

// Effect to update breadcrumb items when dependencies change
$effect(() => {
breadcrumbItems = items || generateBreadcrumbItems($page, routeModules);
});

// Generate breadcrumb items from the current route
function generateBreadcrumbItems(page, modules): BreadcrumbItem[] {
const path = page.url.pathname;
const pathSegments = path.split("/").filter((segment) => segment !== "");

// Always include home page
const items: BreadcrumbItem[] = [{ text: "Home", href: "/" }];

// Build up paths for each breadcrumb
let currentPath = "";

for (let i = 0; i < pathSegments.length; i++) {
currentPath += `/${pathSegments[i]}`;

// Check if this path has a corresponding +page.svelte file
const isValidRoute = isPathValid(currentPath, modules);

// Add breadcrumb if it's a valid route or the current page
if (isValidRoute || i === pathSegments.length - 1) {
// Try to get custom title from module exports
const customTitle = getCustomTitle(currentPath, modules, page.data);

// Format text (convert slug to readable text)
const text = customTitle || formatBreadcrumbText(pathSegments[i]);

items.push({
text: text,
href: currentPath,
});
}
}

return items;
}

// Check if a path has a corresponding page component
function isPathValid(path: string, modules: Record<string, any>): boolean {
// For the root path
if (path === "/") return true;

// Normalize the path to check against module paths
const modulePath = `/src/routes${path}/+page.svelte`;
const dynamicModulePaths = Object.keys(modules).filter((m) => {
// Convert dynamic route patterns like [id] to regex pattern that matches :id or actual values
const pattern = m.replace(/\[([^\]]+)\]/g, "([^/]+)");
const regex = new RegExp(
`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`,
);
return regex.test(modulePath);
});

return dynamicModulePaths.length > 0;
}

// Try to get custom title from module exports
function getCustomTitle(
path: string,
modules: Record<string, any>,
pageData: any,
): string | null {
const modulePath = `/src/routes${path}/+page.svelte`;

// Check for exact match first
if (modules[modulePath]) {
// Check if module exports pageTitle
if (modules[modulePath].pageTitle) {
return modules[modulePath].pageTitle;
}

// Check if module exports getPageTitle function
if (typeof modules[modulePath].getPageTitle === "function") {
return modules[modulePath].getPageTitle(pageData);
}
}

// Check for dynamic routes
const dynamicModulePath = Object.keys(modules).find((m) => {
const pattern = m.replace(/\[([^\]]+)\]/g, "([^/]+)");
const regex = new RegExp(
`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`,
);
return regex.test(modulePath);
});

if (dynamicModulePath && modules[dynamicModulePath]) {
if (modules[dynamicModulePath].pageTitle) {
return modules[dynamicModulePath].pageTitle;
}

if (typeof modules[dynamicModulePath].getPageTitle === "function") {
return modules[dynamicModulePath].getPageTitle(pageData);
}
}

return null;
}

/**
* Converts route segments to human-readable text
*/
function formatBreadcrumbText(text: string): string {
// Handle special cases
if (text === "ui") return "UI Components";
if (text === "data-vis") return "Data Visualization";
if (text === "content") return "Content Components";
if (text === "layout") return "Layout Components";
if (text === "user-guide") return "User Guide";
if (text === "playground") return "Playground";
if (text === "components") return "Components";
if (text === "local-lib") return "Local Library";

// Default: capitalize and replace hyphens with spaces
return text
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
</script>

<nav
class="govuk-breadcrumbs
{collapseOnMobile ? 'govuk-breadcrumbs--collapse-on-mobile' : ''}
{inverse ? 'govuk-breadcrumbs--inverse' : ''}"
aria-label={ariaLabel}
>
<ol class="govuk-breadcrumbs__list">
{#each breadcrumbItems as item}
<li class="govuk-breadcrumbs__list-item">
<a class="govuk-breadcrumbs__link" href={item.href}>{item.text}</a>
</li>
{/each}
</ol>
</nav>
7 changes: 3 additions & 4 deletions src/lib/components/ui/CheckBox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
};

// Component props
const {
let {
legend,
hint,
error,
Expand All @@ -24,6 +24,7 @@
small = false,
options = [],
validate = undefined,
selectedValues = $bindable([]),
} = $props<{
legend: string;
hint?: string;
Expand All @@ -34,11 +35,9 @@
small?: boolean;
options?: CheckboxOption[];
validate?: (values: string[]) => string | undefined;
selectedValues?: string[];
}>();

// Component state
let selectedValues = $state<string[]>([]);

// Add support detection
let isSupported = $state(false);
// Check for browser support on mount
Expand Down
27 changes: 10 additions & 17 deletions src/lib/components/ui/Radios.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
};

// Component props
const {
let {
selectedValue = $bindable(null),
legend,
hint,
error,
Expand All @@ -36,28 +37,19 @@
inline?: boolean;
options?: RadioOption[];
validate?: (value: string) => string | undefined;
selectedValue?: string | null;
}>();

// Component state for single selection
let selectedValue = $state<string | null>(null);

// Add support detection
let isSupported = $state(false);
onMount(() => {
isSupported =
document.body?.classList.contains("govuk-frontend-supported") ?? false;
});

// Derived state to check if a value is selected and handle validation
let isChecked = $derived((value: string) => selectedValue === value);
let validationError = $derived<string | undefined>(
isSupported && validate ? validate(selectedValue ?? "") : undefined,
);

function toggleRadio(option: RadioOption) {
if (!isSupported) return;
selectedValue = selectedValue === option.value ? null : option.value;
}
</script>

<div
Expand Down Expand Up @@ -101,7 +93,9 @@
{/if}

<div
class="govuk-radios{small ? ' govuk-radios--small' : ''}{inline ? ' govuk-radios--inline' : ''}"
class="govuk-radios{small ? ' govuk-radios--small' : ''}{inline
? ' govuk-radios--inline'
: ''}"
data-module="govuk-radios"
role="radiogroup"
aria-labelledby="{name}-legend"
Expand All @@ -120,23 +114,22 @@
<div
class="govuk-radios__item"
role="radio"
aria-checked={isSupported ? isChecked(option.value) : null}
aria-checked={isSupported ? selectedValue === option.value : null}
>
<input
type="radio"
{name}
id="{name}-{i}"
class="govuk-radios__input"
value={option.value}
bind:group={selectedValue}
data-aria-controls={option.conditional?.id}
aria-describedby={[
option.hint ? `${name}-${i}-hint` : null,
option.conditional ? option.conditional.id : null,
]
.filter(Boolean)
.join(" ")}
checked={isChecked(option.value)}
onchange={() => toggleRadio(option)}
data-behaviour={option.exclusive ? "exclusive" : undefined}
/>
<label
Expand All @@ -162,7 +155,7 @@
<div
id={option.conditional.id}
class="govuk-radios__conditional{!isSupported ||
!isChecked(option.value)
selectedValue !== option.value
? ' govuk-radios__conditional--hidden'
: ''}"
role="region"
Expand All @@ -180,4 +173,4 @@
{/each}
</div>
</fieldset>
</div>
</div>
5 changes: 5 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import DividerLine from "$lib/components/layout/DividerLine.svelte";
import Breadcrumbs from "$lib/components/ui/Breadcrumbs.svelte";
import "../app.css";
let { children, data } = $props();

Expand All @@ -21,5 +22,9 @@
</div>
</a>

<div class="g-top-level-container mb-6">
<Breadcrumbs collapseOnMobile={true} />
</div>

{@render children()}
</div>
Loading