mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Fix chart options, marketing paths and silent failures in shared components (#3093)
This commit is contained in:
1 parent
bf576f10a4
commit
8636174130
39 files changed
+157
-73
No files matched your search
@@ -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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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'))
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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']) {
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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('../..')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('/') : '.'
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
Reference in new issue
Block a user