Fix chart options, marketing paths and silent failures in shared components (#3093)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-18 16:16:30 +02:00
1 parent bf576f10a4
commit 8636174130
39 files changed
+157 -73

No files matched your search

+11
View File
@@ -145,8 +145,19 @@ export function copyAssets({ repo, publicDir, copies, syncDirs = [] }: CopyAsset
return return
} }
} }
// A file deleted from a source dir (a renamed bundle, a cleaned dist) must
// leave public/ too, or the dev server keeps serving it until a restart.
const remove = (file: string) => {
for (const { from, to } of syncDirs) {
if (!file.startsWith(from + sep)) continue
rmSync(join(to, relative(from, file)), { force: true })
scheduleReload(file)
return
}
}
server.watcher.on('add', sync) server.watcher.on('add', sync)
server.watcher.on('change', sync) server.watcher.on('change', sync)
server.watcher.on('unlink', remove)
}, },
}, },
} }
-2
View File
@@ -10,8 +10,6 @@ docs-classnames
docs-page-lead docs-page-lead
docs-search docs-search
docs-theme-toggle docs-theme-toggle
dropzone-msg-desc
dropzone-msg-title
fallback fallback
github-dark github-dark
icon-tabler icon-tabler
-8
View File
@@ -46,14 +46,6 @@ export function createViteConfig({ entry, name, fileName, formats, outDir, banne
define: { define: {
'process.env.NODE_ENV': '"production"', 'process.env.NODE_ENV': '"production"',
}, },
esbuild: {
target: 'es2015',
tsconfigRaw: {
compilerOptions: {
target: 'ES2015',
},
},
},
} }
return defineConfig(config) return defineConfig(config)
@@ -0,0 +1,6 @@
---
"@tabler/preview": patch
"@tabler/docs": patch
---
Fixed `Trending` signs, ApexCharts 7 legend markers, marketing asset paths, `pageLibs` checks and the notifications close button.
+1 -1
View File
@@ -78,7 +78,7 @@ Add the `multiple` attribute to the input to accept several files at once, for e
You can replace the default message in the drop area with your own markup, for example an icon and a hint about the accepted file types: You can replace the default message in the drop area with your own markup, for example an icon and a hint about the accepted file types:
<Example> <Example>
<form class="dropzone" id="dropzone-custom" action="." autocomplete="off" novalidate> <div class="fallback"> <input name="file" type="file" /> </div> <div class="dz-message"> <h3 class="dropzone-msg-title">Your text here</h3> <span class="dropzone-msg-desc">Your custom description here</span> <button type="button" class="btn btn-sm mt-3">Select files</button> </div> </form> <script>{`document.addEventListener("DOMContentLoaded", function () { new Dropzone("#dropzone-custom"); });`}</script> <form class="dropzone" id="dropzone-custom" action="." autocomplete="off" novalidate> <div class="fallback"> <input name="file" type="file" /> </div> <div class="dz-message"> <h3>Your text here</h3> <span>Your custom description here</span> <button type="button" class="btn btn-sm mt-3">Select files</button> </div> </form> <script>{`document.addEventListener("DOMContentLoaded", function () { new Dropzone("#dropzone-custom"); });`}</script>
</Example> </Example>
Custom content replaces the button Dropzone renders by default, and that button is the only part of the drop area a keyboard can reach. Keep a focusable control - such as the one above - inside your own message. Custom content replaces the button Dropzone renders by default, and that button is the only part of the drop area a keyboard can reach. Keep a focusable control - such as the one above - inside your own message.
+3 -1
View File
@@ -22,10 +22,12 @@ const alternate = (path: string, type: string) => `<${path}>; rel="alternate"; t
export default function middleware(request: Request) { export default function middleware(request: Request) {
const url = new URL(request.url) const url = new URL(request.url)
// redirect keys have no trailing slash, so "/ui/base/markdown/" must match "/ui/base/markdown"
const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, '') : url.pathname
// renamed pages fall through to their 301; negotiation reruns on the target // renamed pages fall through to their 301; negotiation reruns on the target
// not Object.hasOwn — Vercel's middleware type-check runs with a pre-es2022 lib // not Object.hasOwn — Vercel's middleware type-check runs with a pre-es2022 lib
if (Object.prototype.hasOwnProperty.call(redirects, url.pathname)) return next() if (Object.prototype.hasOwnProperty.call(redirects, path)) return next()
const format = preferredFormat(request.headers.get('accept')) const format = preferredFormat(request.headers.get('accept'))
+1 -1
View File
@@ -70,7 +70,7 @@ function greetUser(name) {
}` }`
--- ---
<DefaultLayout title="All Elements - Kitchen Sink" pageHeader="All Elements" pageMenu="base.all-elements" pageLibs={['nouislider', 'star-rating.js', 'tabler-flags', 'tabler-payments', 'tom-select']}> <DefaultLayout title="All Elements - Kitchen Sink" pageHeader="All Elements" pageMenu="base.all-elements" pageLibs={['nouislider', 'star-rating.js', 'tom-select']}>
<div class="row row-cards"> <div class="row row-cards">
<!-- Typography Section --> <!-- Typography Section -->
<div class="col-12"> <div class="col-12">
+1 -1
View File
@@ -192,7 +192,7 @@ const operationCurrencies = currencies.slice(0, 20)
<td class="">{market.price}</td> <td class="">{market.price}</td>
<td class="d-none d-xl-table-cell">{market.volume}</td> <td class="d-none d-xl-table-cell">{market.volume}</td>
<td class="d-none d-xl-table-cell text-end"> <td class="d-none d-xl-table-cell text-end">
<Trending value={market.change as unknown as number} /> <Trending value={market.change} />
</td> </td>
</tr> </tr>
)) ))
+1 -1
View File
@@ -52,7 +52,7 @@ const rows = [
] ]
--- ---
<DefaultLayout title="Form elements" pageMenu="form.elements" pageLibs={['nouislider', 'tabler-flags', 'tabler-payments', 'litepicker', 'tom-select', 'imask']}> <DefaultLayout title="Form elements" pageMenu="form.elements" pageLibs={['nouislider', 'litepicker', 'tom-select', 'imask']}>
<DocsLink slot="page-header-actions" path="/ui/forms/elements" /> <DocsLink slot="page-header-actions" path="/ui/forms/elements" />
<div class="row row-cards"> <div class="row row-cards">
<div class="col-12"> <div class="col-12">
+1 -1
View File
@@ -7,7 +7,7 @@ import FormGroup from '@ui/FormGroup.astro'
import Icon from '@ui/Icon.astro' import Icon from '@ui/Icon.astro'
--- ---
<PayLayout title="Pay" pageLibs={['tabler-payments', 'imask']}> <PayLayout title="Pay">
<div class="bg-dark bg-cover" style="background-image: url(./static/bg-cover.jpg)"> <div class="bg-dark bg-cover" style="background-image: url(./static/bg-cover.jpg)">
<div class="bg-dark bg-opacity-75 pt-5 bg-blur"> <div class="bg-dark bg-opacity-75 pt-5 bg-blur">
<div class="container container-tight"> <div class="container container-tight">
+1 -1
View File
@@ -102,7 +102,7 @@ import DocsLink from '@ui/DocsLink.astro'
<h2>Supporting images</h2> <h2>Supporting images</h2>
<p>Images should support the text and provide useful context, not just decorate the page.</p> <p>Images should support the text and provide useful context, not just decorate the page.</p>
<figure> <figure>
<img src="/static/photos/cup-of-coffee-and-an-open-book.jpg" alt="Open book and coffee" /> <img src="./static/photos/cup-of-coffee-and-an-open-book.jpg" alt="Open book and coffee" />
<figcaption class="text-secondary small mt-2">A quiet spot makes for better writing sessions.</figcaption> <figcaption class="text-secondary small mt-2">A quiet spot makes for better writing sessions.</figcaption>
</figure> </figure>
+2 -2
View File
@@ -4,7 +4,7 @@ import Logo from '@shared/components/navbar/Logo.astro'
import { site } from '@shared/lib/site' import { site } from '@shared/lib/site'
--- ---
<BaseLayout title="Screenshot" pageLibs={['signature_pad']}> <BaseLayout title="Screenshot">
<style is:inline> <style is:inline>
.screenshot { .screenshot {
width: 1024px; width: 1024px;
@@ -40,7 +40,7 @@ import { site } from '@shared/lib/site'
} }
.avatar-2 { .avatar-2 {
background-image: url(/static/avatars/032f.jpg); background-image: url(./static/avatars/032f.jpg);
} }
</style> </style>
+1 -1
View File
@@ -8,7 +8,7 @@ import CardSubtitle from '@ui/CardSubtitle.astro'
import DocsLink from '@ui/DocsLink.astro' import DocsLink from '@ui/DocsLink.astro'
--- ---
<DefaultLayout title="Star Ratings" pageMenu="base.stars-rating" pageLibs={['tabler-flags', 'star-rating.js']} description="An interactive star rating built on the star-rating.js widget, backed by a hidden select for accessibility and forms."> <DefaultLayout title="Star Ratings" pageMenu="base.stars-rating" pageLibs={['star-rating.js']} description="An interactive star rating built on the star-rating.js widget, backed by a hidden select for accessibility and forms.">
<DocsLink slot="page-header-actions" path="/ui/components/star-rating" /> <DocsLink slot="page-header-actions" path="/ui/components/star-rating" />
<div class="row row-cards mb-5"> <div class="row row-cards mb-5">
+4 -4
View File
@@ -57,7 +57,7 @@ const homepage = siteData.homepage
Heading {i} by{' '} Heading {i} by{' '}
<a class="mention"> <a class="mention">
<> <>
<span class="mention-avatar" style="background-image: url(/static/avatars/035f.jpg)" /> <span class="mention-avatar" style="background-image: url(./static/avatars/035f.jpg)" />
<span class="visually-hidden">@</span> <span class="visually-hidden">@</span>
</> </>
JohnDoe JohnDoe
@@ -76,9 +76,9 @@ const homepage = siteData.homepage
</p> </p>
<p> <p>
Hey <a class="mention"><span class="mention-avatar" style="background-image: url(/static/avatars/035f.jpg)"></span><span class="visually-hidden">@</span>JohnDoe</a>, have you seen the latest updates on <a class="mention">#WebDevelopment<span class="mention-count">16</span></a>? <a class="mention" Hey <a class="mention"><span class="mention-avatar" style="background-image: url(./static/avatars/035f.jpg)"></span><span class="visually-hidden">@</span>JohnDoe</a>, have you seen the latest updates on <a class="mention">#WebDevelopment<span class="mention-count">16</span></a>? <a class="mention"
><span class="mention-avatar" style="background-image: url(/static/avatars/035f.jpg)"></span><span class="visually-hidden">@</span>JaneSmith</a ><span class="mention-avatar" style="background-image: url(./static/avatars/035f.jpg)"></span><span class="visually-hidden">@</span>JaneSmith</a
> just shared an interesting article about <a class="mention"><span class="mention-app" style="background-image: url(/static/brands/messenger.svg)"></span>Messenger</a> and <a class="mention"><span class="mention-app" style="background-image: url(/static/brands/netflix.svg)"></span>Netflix</a>! > just shared an interesting article about <a class="mention"><span class="mention-app" style="background-image: url(./static/brands/messenger.svg)"></span>Messenger</a> and <a class="mention"><span class="mention-app" style="background-image: url(./static/brands/netflix.svg)"></span>Netflix</a>!
</p> </p>
<p> <p>
+1 -1
View File
@@ -15,7 +15,7 @@ import DocsLink from '@ui/DocsLink.astro'
<p class="lead">Good coffee isn't about expensive gear — it's about <mark>consistency</mark>. Get the ratio, the grind, and the water temperature right, and almost any method will taste better.</p> <p class="lead">Good coffee isn't about expensive gear — it's about <mark>consistency</mark>. Get the ratio, the grind, and the water temperature right, and almost any method will taste better.</p>
<figure> <figure>
<img src="/static/photos/cup-of-coffee-and-an-open-book.jpg" alt="A cup of coffee next to an open book" /> <img src="./static/photos/cup-of-coffee-and-an-open-book.jpg" alt="A cup of coffee next to an open book" />
<figcaption class="text-secondary small mt-2">Ten minutes of prep, and the rest of the morning is yours.</figcaption> <figcaption class="text-secondary small mt-2">Ten minutes of prep, and the rest of the morning is yours.</figcaption>
</figure> </figure>
+7 -3
View File
@@ -44,9 +44,13 @@ const avatarClass = [color ? `bg-${color}${lt ? '-lt' : ' text-white'}` : '', 'a
</div> </div>
</div> </div>
<div class="col-auto"> {
<Trending value={changeValue as unknown as number} unit={changeValueUnit} /> changeValue !== undefined && (
</div> <div class="col-auto">
<Trending value={changeValue} unit={changeValueUnit} />
</div>
)
}
</div> </div>
</div> </div>
</div> </div>
+10 -2
View File
@@ -31,8 +31,16 @@ const {
} = Astro.props; } = Astro.props;
const id = idProp ?? keyProp; const id = idProp ?? keyProp;
if (!id) {
throw new Error('DataSelect: pass an `id` or a `key` — the select needs one for its id');
}
const key = keyProp ?? 'people'; const key = keyProp ?? 'people';
const data = (selects as Record<string, any>)[key] ?? {}; let data = (selects as Record<string, any>)[key];
if (!data && !values) {
throw new Error(`DataSelect: unknown key "${key}" — not in @data/selects.json`);
}
// `values` alone needs no selects.json entry.
data ??= {};
const isMultiple = Boolean(data.multiple); const isMultiple = Boolean(data.multiple);
@@ -77,4 +85,4 @@ if (values) {
} }
--- ---
{id && <Select id={id} class={className} state={state} placeholder={placeholder} multiple={isMultiple} options={options} optgroups={optgroups} />} <Select id={id} class={className} state={state} placeholder={placeholder} multiple={isMultiple} options={options} optgroups={optgroups} />
@@ -1,8 +1,8 @@
--- ---
// Relative base is "../" for one-level-deep marketing pages.
import Logo from '../navbar/Logo.astro' import Logo from '../navbar/Logo.astro'
import { relativeBase } from '@shared/lib/url'
const base = '..' const base = relativeBase(Astro.url.pathname)
--- ---
<!-- BEGIN MARKETING NAVBAR --> <!-- BEGIN MARKETING NAVBAR -->
@@ -16,7 +16,7 @@ const base = '..'
<nav aria-label="Primary"> <nav aria-label="Primary">
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" href={`${base}/marketing`}><span class="nav-link-title">Home</span></a> <a class="nav-link active" href={`${base}/marketing.html`}><span class="nav-link-title">Home</span></a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href={`${base}/marketing/testimonials.html`}><span class="nav-link-title">Testimonials</span></a> <a class="nav-link" href={`${base}/marketing/testimonials.html`}><span class="nav-link-title">Testimonials</span></a>
@@ -1,6 +1,9 @@
--- ---
import Icon from '@ui/Icon.astro' import Icon from '@ui/Icon.astro'
import { site } from '@shared/lib/site' import { site } from '@shared/lib/site'
import { relativeBase } from '@shared/lib/url'
const base = relativeBase(Astro.url.pathname)
--- ---
<!-- BEGIN HERO --> <!-- BEGIN HERO -->
@@ -38,7 +41,7 @@ import { site } from '@shared/lib/site'
</div> </div>
</div> </div>
<picture> <picture>
<img src="../static/marketing/preview.png" srcset="../static/marketing/preview.png 1x, ../static/marketing/preview@2x.png 2x" alt="" class="img-fluid" width="1040" height="760" loading="lazy" /> <img src={`${base}/static/marketing/preview.png`} srcset={`${base}/static/marketing/preview.png 1x, ${base}/static/marketing/preview@2x.png 2x`} alt="" class="img-fluid" width="1040" height="760" loading="lazy" />
</picture> </picture>
</div> </div>
</div> </div>
@@ -1,8 +1,8 @@
--- ---
// Marketing pages live one level deep, so the avatar asset base is "..", passed to Avatar via `base`.
import testimonials from '@data/testimonials.json' import testimonials from '@data/testimonials.json'
import { people } from '@shared/lib/people' import { people } from '@shared/lib/people'
import Avatar from '@ui/Avatar.astro' import Avatar from '@ui/Avatar.astro'
import { relativeBase } from '@shared/lib/url'
interface Props { interface Props {
class?: string class?: string
@@ -12,12 +12,15 @@ interface Props {
const { class: className, limit = 99, hideHeader } = Astro.props const { class: className, limit = 99, hideHeader } = Astro.props
const base = relativeBase(Astro.url.pathname)
const sectionClass = ['section', className] const sectionClass = ['section', className]
// Slice testimonials and split into 3 columns. // Slice testimonials and split into 3 columns.
const list = (testimonials as string[]).slice(0, limit) const list = (testimonials as string[]).slice(0, limit)
const n = 3 const n = 3
const chunkSize = Math.round(list.length / n) // At least 1, or a limit below 2 would never advance the loop below.
const chunkSize = Math.max(1, Math.round(list.length / n))
const groups: string[][] = [] const groups: string[][] = []
for (let i = 0; i < list.length; i += chunkSize) { for (let i = 0; i < list.length; i += chunkSize) {
groups.push(list.slice(i, i + chunkSize)) groups.push(list.slice(i, i + chunkSize))
@@ -56,7 +59,7 @@ const cards = groups.map((group) =>
<div class="card-body"> <div class="card-body">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-auto"> <div class="col-auto">
<Avatar person={person} size="md" base=".." /> <Avatar person={person} size="md" base={base} />
</div> </div>
<div class="col"> <div class="col">
<h3 class="h3 m-0">{person.full_name}</h3> <h3 class="h3 m-0">{person.full_name}</h3>
+4 -4
View File
@@ -1,6 +1,6 @@
--- ---
import Icon from '@ui/Icon.astro' import Icon from '@ui/Icon.astro'
import NavbarMenuItem from './NavbarMenuItem.astro' import NavbarMenuItem, { type Level2 } from './NavbarMenuItem.astro'
import menu from '@data/menu.json' import menu from '@data/menu.json'
import menuSample from '@data/menu-sample.json' import menuSample from '@data/menu-sample.json'
@@ -16,7 +16,7 @@ interface Level1 {
'columns'?: number 'columns'?: number
/** section label rendered before this item (sidebar only, needs the `sections` prop) */ /** section label rendered before this item (sidebar only, needs the `sections` prop) */
'section'?: string 'section'?: string
'children'?: Record<string, Record<string, unknown>> 'children'?: Record<string, Level2>
} }
interface Props { interface Props {
@@ -84,13 +84,13 @@ const chunk = <T,>(arr: T[], size: number): T[][] => {
{columns.map((column) => ( {columns.map((column) => (
<div class="dropdown-menu-column"> <div class="dropdown-menu-column">
{column.map(([childKey, child]) => ( {column.map(([childKey, child]) => (
<NavbarMenuItem level1Key={key} itemKey={childKey} item={child as any} currentPage={currentPage} keepOpen={keepOpen} /> <NavbarMenuItem level1Key={key} itemKey={childKey} item={child} currentPage={currentPage} keepOpen={keepOpen} />
))} ))}
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
children.map(([childKey, child]) => <NavbarMenuItem level1Key={key} itemKey={childKey} item={child as any} currentPage={currentPage} keepOpen={keepOpen} />) children.map(([childKey, child]) => <NavbarMenuItem level1Key={key} itemKey={childKey} item={child} currentPage={currentPage} keepOpen={keepOpen} />)
)} )}
</div> </div>
)} )}
@@ -3,13 +3,13 @@
import { isExternal } from '@shared/lib/url' import { isExternal } from '@shared/lib/url'
import Icon from '@ui/Icon.astro' import Icon from '@ui/Icon.astro'
interface Level3 { export interface Level3 {
title: string title: string
url?: string url?: string
badge?: string badge?: string
} }
interface Level2 extends Level3 { export interface Level2 extends Level3 {
icon?: string icon?: string
color?: string color?: string
children?: Record<string, Level3> children?: Record<string, Level3>
@@ -99,11 +99,13 @@ import CaptureScript from '../CaptureScript.astro'
<CaptureScript> <CaptureScript>
<!-- BEGIN NAVBAR NOTIFICATIONS CLOSE --> <!-- BEGIN NAVBAR NOTIFICATIONS CLOSE -->
<script is:inline> <script is:inline>
// onclick, not addEventListener: a page with several navbars runs this once per navbar,
// and assigning the property keeps it at one handler per button.
for (const close of document.querySelectorAll('[data-navbar-notifications-close]')) { for (const close of document.querySelectorAll('[data-navbar-notifications-close]')) {
close.addEventListener('click', () => { close.onclick = () => {
const toggle = close.closest('.dropdown').querySelector('[data-bs-toggle="dropdown"]') const toggle = close.closest('.dropdown').querySelector('[data-bs-toggle="dropdown"]')
window.tabler.bootstrap.Dropdown.getOrCreateInstance(toggle).hide() window.tabler.bootstrap.Dropdown.getOrCreateInstance(toggle).hide()
}) }
} }
</script> </script>
<!-- END NAVBAR NOTIFICATIONS CLOSE --> <!-- END NAVBAR NOTIFICATIONS CLOSE -->
+4
View File
@@ -36,6 +36,10 @@ const min = isDevelopment ? '' : '.min'
const rtlSuffix = rtl ? '.rtl' : '' const rtlSuffix = rtl ? '.rtl' : ''
type Lib = { npm?: string; js?: string[]; css?: string[]; head?: boolean } type Lib = { npm?: string; js?: string[]; css?: string[]; head?: boolean }
const unknownLibs = pageLibs.filter((name) => !(name in libs))
if (unknownLibs.length > 0) {
throw new Error(`BaseLayout: unknown pageLibs ${unknownLibs.map((name) => `"${name}"`).join(', ')} — use a key from core/libs.json`)
}
const pageLibEntries = Object.entries(libs as Record<string, Lib>).filter(([name]) => pageLibs.includes(name)) const pageLibEntries = Object.entries(libs as Record<string, Lib>).filter(([name]) => pageLibs.includes(name))
// Google Maps API key comes from the environment (GOOGLE_MAPS_KEY in .env or the // Google Maps API key comes from the environment (GOOGLE_MAPS_KEY in .env or the
// host's settings), never from the source. Without it the script loads keyless. // host's settings), never from the source. Without it the script loads keyless.
+2 -1
View File
@@ -5,6 +5,7 @@ import MarketingNavbar from '@shared/components/marketing/MarketingNavbar.astro'
import Payment from '@ui/Payment.astro' import Payment from '@ui/Payment.astro'
import Icon from '@ui/Icon.astro' import Icon from '@ui/Icon.astro'
import { site } from '@shared/lib/site' import { site } from '@shared/lib/site'
import { relativeBase } from '@shared/lib/url'
interface Props { interface Props {
title?: string title?: string
@@ -14,7 +15,7 @@ interface Props {
const { title, pageLibs } = Astro.props const { title, pageLibs } = Astro.props
const base = '..' const base = relativeBase(Astro.url.pathname)
--- ---
<BaseLayout title={title} pageLibs={pageLibs} bodyClass="body-marketing body-gradient" base={base}> <BaseLayout title={title} pageLibs={pageLibs} bodyClass="body-marketing body-gradient" base={base}>
+11
View File
@@ -136,4 +136,15 @@ describe('chartConfig', () => {
expect(xFormatterExpr).toBe('val + "K"') expect(xFormatterExpr).toBe('val + "K"')
expect(JSON.stringify(config)).not.toContain('val + "K"') expect(JSON.stringify(config)).not.toContain('val + "K"')
}) })
it('uses the ApexCharts 7 legend marker options', () => {
const { config } = chartConfig({ id: 'demo', data: { series: [], legend: true }, height: 10 })
expect(config.legend).toMatchObject({ markers: { size: 5, strokeWidth: 0, shape: 'circle' } })
})
it('hides line points, including the hover dot, with hide-points', () => {
const { config } = chartConfig({ id: 'demo', data: { 'series': [], 'hide-points': true }, height: 10 })
expect(config.markers).toEqual({ size: 0, hover: { sizeOffset: 0 } })
expect(config).not.toHaveProperty('point')
})
}) })
+4 -2
View File
@@ -444,7 +444,8 @@ export function chartConfig(opts: { id: string; data: ChartData; height: number
show: true, show: true,
position: 'bottom', position: 'bottom',
offsetY: 12, offsetY: 12,
markers: { width: 10, height: 10, radius: 100 }, // v7 sizes the legend dot as (size + strokeWidth) * 2 — a 10px circle.
markers: { size: 5, strokeWidth: 0, shape: 'circle' },
itemMargin: { horizontal: 8, vertical: 8 }, itemMargin: { horizontal: 8, vertical: 8 },
} }
: { show: false } : { show: false }
@@ -458,7 +459,8 @@ export function chartConfig(opts: { id: string; data: ChartData; height: number
} }
if (data['hide-points']) { if (data['hide-points']) {
config.point = { show: false } // hover.size 0 counts as unset in v7, so the hover dot is removed through sizeOffset.
config.markers = { size: 0, hover: { sizeOffset: 0 } }
} }
if (data['show-markers']) { if (data['show-markers']) {
+7 -3
View File
@@ -12,9 +12,13 @@ describe('formatCommitDate', () => {
}) })
describe('toUnixSeconds', () => { describe('toUnixSeconds', () => {
it('shifts the wall-clock epoch by the local timezone offset', () => { it('returns the true epoch seconds', () => {
const date = new Date(Date.UTC(2024, 0, 1, 0, 0, 0)) expect(toUnixSeconds(new Date(Date.UTC(2024, 0, 1, 0, 0, 0)))).toBe(1704067200)
expect(toUnixSeconds(date)).toBe(Math.floor(date.getTime() / 1000) + date.getTimezoneOffset() * 60) })
it('does not depend on DST (summer and winter dates)', () => {
expect(toUnixSeconds(new Date(Date.UTC(2024, 6, 1, 12, 0, 0)))).toBe(1719835200)
expect(toUnixSeconds(new Date(Date.UTC(2024, 0, 1, 12, 0, 0, 999)))).toBe(1704110400)
}) })
}) })
+4 -5
View File
@@ -1,12 +1,11 @@
// Demo data date formatting quirks: // Demo data is rendered at build time, so every formatter reads UTC components
// - unix seconds are shifted by local timezone offset (not the true epoch) // and never the local timezone — output must match between CI and local builds.
// - long-date format uses UTC wall-clock components
const SHORT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] const SHORT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
/** Unix seconds shifted by the local timezone offset (not the true epoch). */ /** Unix epoch seconds, independent of the build machine's timezone. */
export function toUnixSeconds(date: Date): number { export function toUnixSeconds(date: Date): number {
return Math.floor(date.getTime() / 1000) + date.getTimezoneOffset() * 60 return Math.floor(date.getTime() / 1000)
} }
/** Formats a date as "Month DD, YYYY" using its UTC components. */ /** Formats a date as "Month DD, YYYY" using its UTC components. */
+4
View File
@@ -42,6 +42,10 @@ describe('freeIllustrationSource', () => {
it('returns the auto-dark source of a bundled illustration', () => { it('returns the auto-dark source of a bundled illustration', () => {
expect(freeIllustrationSource('not-found')).toContain('<svg ') expect(freeIllustrationSource('not-found')).toContain('<svg ')
}) })
it('throws for a name that is not bundled instead of rendering nothing', () => {
expect(() => freeIllustrationSource('no-such-illustration' as never)).toThrow(/Unknown illustration/)
})
}) })
describe('illustrationSvg', () => { describe('illustrationSvg', () => {
+4 -1
View File
@@ -11,7 +11,10 @@ export type IllustrationImage = FreeIllustration | `${FreeIllustration}.svg`
/** Source SVG of a bundled illustration, in its auto-dark variant. */ /** Source SVG of a bundled illustration, in its auto-dark variant. */
export function freeIllustrationSource(image: IllustrationImage): string { export function freeIllustrationSource(image: IllustrationImage): string {
return freeIllustrations.autodark[image.replaceAll('.svg', '') as FreeIllustration] ?? '' const name = image.replace(/\.svg$/, '')
const source = (freeIllustrations.autodark as Record<string, string | undefined>)[name]
if (!source) throw new Error(`Unknown illustration "${image}" — not in @data/free-illustrations.json`)
return source
} }
type IconRecord = { svg: Record<string, string | null | undefined> } type IconRecord = { svg: Record<string, string | null | undefined> }
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isExternal } from './url' import { isExternal, relativeBase } from './url'
describe('isExternal', () => { describe('isExternal', () => {
it('is true for absolute http(s) urls', () => { it('is true for absolute http(s) urls', () => {
@@ -24,3 +24,18 @@ describe('isExternal (anchored)', () => {
expect(isExternal('/redirect?to=https://example.com')).toBe(false) expect(isExternal('/redirect?to=https://example.com')).toBe(false)
}) })
}) })
describe('relativeBase', () => {
it('is "." for top-level pages, including a folder index built as a root file', () => {
expect(relativeBase('/')).toBe('.')
expect(relativeBase('/marketing')).toBe('.')
expect(relativeBase('/marketing/')).toBe('.')
expect(relativeBase('/marketing.html')).toBe('.')
})
it('climbs one level per folder', () => {
expect(relativeBase('/marketing/about')).toBe('..')
expect(relativeBase('/marketing/about.html')).toBe('..')
expect(relativeBase('/a/b/c.html')).toBe('../..')
})
})
+9
View File
@@ -4,3 +4,12 @@
export function isExternal(url?: string): boolean { export function isExternal(url?: string): boolean {
return !!url && /^https?:\/\//.test(url) return !!url && /^https?:\/\//.test(url)
} }
/**
* Relative path from a page back to the site root: "." for a top-level page, ".." one level deep.
* Pages are built with `format: 'file'`, so "/marketing" (or "/marketing.html") is a root file.
*/
export function relativeBase(pathname: string): string {
const depth = pathname.split('/').filter(Boolean).length - 1
return depth > 0 ? Array.from({ length: depth }, () => '..').join('/') : '.'
}
+2 -2
View File
@@ -32,8 +32,8 @@ const classes = ['breadcrumb', className, separator && `breadcrumb-${separator}`
) : ( ) : (
<li class="breadcrumb-item"> <li class="breadcrumb-item">
{index === 0 && homeIcon ? ( {index === 0 && homeIcon ? (
/* Home icon link uses href="/". */ /* Demo link like the other items; "/" would break a preview hosted in a subfolder. */
<a href="/"> <a href="#" aria-label={page}>
<Icon name="home" /> <Icon name="home" />
</a> </a>
) : ( ) : (
+2 -2
View File
@@ -24,8 +24,8 @@ const fileInputId = `${dropzoneId}-file`
{ {
custom && ( custom && (
<div class="dz-message"> <div class="dz-message">
<h3 class="dropzone-msg-title">{text}</h3> <h3>{text}</h3>
<span class="dropzone-msg-desc">{description}</span> <span>{description}</span>
</div> </div>
) )
} }
+2 -2
View File
@@ -41,8 +41,8 @@ const classes = ['empty', className]
) : null ) : null
} }
<Title class="empty-title" set:html={title} /> {title && <Title class="empty-title" set:html={title} />}
<p class="empty-subtitle text-secondary" set:html={description} /> {description && <p class="empty-subtitle text-secondary" set:html={description} />}
{ {
buttonText && ( buttonText && (
<div class="empty-action"> <div class="empty-action">
+1 -4
View File
@@ -20,8 +20,6 @@ interface Props {
const { id, class: className, state, placeholder, multiple, options = [], optgroups = [] } = Astro.props; const { id, class: className, state, placeholder, multiple, options = [], optgroups = [] } = Astro.props;
const value = '';
const selectClass = [ const selectClass = [
'form-select', 'form-select',
className, className,
@@ -34,8 +32,7 @@ const selectId = `select-${id}`;
<select <select
class:list={selectClass} class:list={selectClass}
data-placeholder={placeholder} data-placeholder={placeholder}
id={`select-${id}`} id={selectId}
value={value}
multiple={multiple ? '' : undefined} multiple={multiple ? '' : undefined}
> >
{ {
+10 -4
View File
@@ -2,20 +2,26 @@
import Icon from './Icon.astro' import Icon from './Icon.astro'
interface Props { interface Props {
value: number /** a number, or a signed string such as "+9.5" that is rendered as written */
value: number | string
unit?: string | undefined unit?: string | undefined
class?: string class?: string
} }
const { value, unit = '%', class: className } = Astro.props const { value, unit = '%', class: className } = Astro.props
const trendingColor = value > 0 ? 'green' : value === 0 ? 'muted' : 'red' const amount = Number(value)
const trendingIcon = value > 0 ? 'arrow-up' : value === 0 ? 'minus' : 'arrow-down' if (Number.isNaN(amount)) {
throw new Error(`Trending: value "${value}" is not a number`)
}
const trendingColor = amount > 0 ? 'green' : amount === 0 ? 'muted' : 'red'
const trendingIcon = amount > 0 ? 'arrow-up' : amount === 0 ? 'minus' : 'arrow-down'
const classes = [`text-${trendingColor} d-inline-flex align-items-center lh-1`, className] const classes = [`text-${trendingColor} d-inline-flex align-items-center lh-1`, className]
--- ---
<span class:list={classes}> <span class:list={classes}>
<span class="visually-hidden">{value > 0 ? 'Increased by' : value < 0 ? 'Decreased by' : 'No change,'} </span>{value}{unit} <span class="visually-hidden">{amount > 0 ? 'Increased by' : amount < 0 ? 'Decreased by' : 'No change,'} </span>{value}{unit}
<Icon name={trendingIcon} class="ms-0" size="sm" /> <Icon name={trendingIcon} class="ms-0" size="sm" />
</span> </span>
+1 -1
View File
@@ -17,7 +17,7 @@
"@tabler/docs#build": { "@tabler/docs#build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.build/**", "$TURBO_ROOT$/.browserslistrc", "$TURBO_ROOT$/shared/**", "!$TURBO_ROOT$/shared/.turbo/**", "!$TURBO_ROOT$/shared/.astro/**", "!$TURBO_ROOT$/shared/**/node_modules/**"], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.build/**", "$TURBO_ROOT$/.browserslistrc", "$TURBO_ROOT$/shared/**", "!$TURBO_ROOT$/shared/.turbo/**", "!$TURBO_ROOT$/shared/.astro/**", "!$TURBO_ROOT$/shared/**/node_modules/**"],
"outputs": ["dist/**", "tmp-assets/**"], "outputs": ["dist/**", "tmp-assets/**", ".vercel/output/**"],
"cache": true "cache": true
}, },
"@tabler/screenshots#build": { "@tabler/screenshots#build": {