@svadmin/ui 0.75.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.css +383 -22
- package/dist/app.theme.css +383 -22
- package/dist/component-registry.d.ts +300 -0
- package/dist/component-registry.js +2 -0
- package/dist/components/AppFooter.svelte +137 -0
- package/dist/components/AppFooter.svelte.d.ts +25 -0
- package/dist/components/CodeEditor.svelte +145 -0
- package/dist/components/CodeEditor.svelte.d.ts +26 -0
- package/dist/components/HighlightText.svelte +46 -0
- package/dist/components/HighlightText.svelte.d.ts +13 -0
- package/dist/components/JsonEditor.svelte +182 -0
- package/dist/components/JsonEditor.svelte.d.ts +22 -0
- package/dist/components/Layout.svelte +38 -5
- package/dist/components/Layout.svelte.d.ts +4 -3
- package/dist/components/Marquee.svelte +104 -0
- package/dist/components/Marquee.svelte.d.ts +20 -0
- package/dist/components/MediaPlayer.svelte +104 -0
- package/dist/components/MediaPlayer.svelte.d.ts +21 -0
- package/dist/components/PhoneInput.svelte +157 -0
- package/dist/components/PhoneInput.svelte.d.ts +26 -0
- package/dist/components/QRCode.svelte +88 -0
- package/dist/components/QRCode.svelte.d.ts +22 -0
- package/dist/components/ResizableGrid.svelte +252 -0
- package/dist/components/ResizableGrid.svelte.d.ts +34 -0
- package/dist/components/Sidebar.svelte +1 -1
- package/dist/components/TopProgressBar.svelte +90 -0
- package/dist/components/TopProgressBar.svelte.d.ts +15 -0
- package/dist/components/browser.svelte.d.ts +65 -0
- package/dist/components/browser.svelte.js +228 -0
- package/dist/components/charts/AreaChart.svelte +78 -0
- package/dist/components/charts/AreaChart.svelte.d.ts +17 -0
- package/dist/components/charts/ScatterChart.svelte +82 -0
- package/dist/components/charts/ScatterChart.svelte.d.ts +17 -0
- package/dist/components/charts/index.d.ts +2 -0
- package/dist/components/charts/index.js +2 -0
- package/dist/components/code-editor-presets.d.ts +14 -0
- package/dist/components/code-editor-presets.js +14 -0
- package/dist/components/content/ContentPageHeader.svelte +17 -3
- package/dist/components/content/ContentPageHeader.svelte.d.ts +1 -0
- package/dist/components/content/ContentPageShell.svelte +12 -3
- package/dist/components/content/ContentPageShell.svelte.d.ts +1 -0
- package/dist/components/content/MetricBlock.svelte +1 -1
- package/dist/components/highlight.d.ts +16 -0
- package/dist/components/highlight.js +68 -0
- package/dist/components/media.svelte.d.ts +27 -0
- package/dist/components/media.svelte.js +60 -0
- package/dist/components/phone-dial-codes.d.ts +19 -0
- package/dist/components/phone-dial-codes.js +112 -0
- package/dist/index.d.ts +19 -1
- package/dist/index.js +14 -1
- package/dist/recipes.d.ts +77 -38
- package/dist/recipes.js +33 -8
- package/package.json +64 -4
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../../utils.js';
|
|
3
|
+
|
|
4
|
+
interface AreaPoint {
|
|
5
|
+
label: string;
|
|
6
|
+
value: number;
|
|
7
|
+
color?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
data: AreaPoint[];
|
|
12
|
+
height?: number;
|
|
13
|
+
/** Stroke width of the value line. */
|
|
14
|
+
strokeWidth?: number;
|
|
15
|
+
/** Fills the area under the line. Defaults to `true`. */
|
|
16
|
+
fill?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
data,
|
|
22
|
+
height = 200,
|
|
23
|
+
strokeWidth = 2,
|
|
24
|
+
fill = true,
|
|
25
|
+
class: className = '',
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
|
|
28
|
+
const width = 480;
|
|
29
|
+
const padding = { top: 16, right: 16, bottom: 28, left: 36 };
|
|
30
|
+
|
|
31
|
+
const maxValue = $derived(Math.max(...data.map((point) => point.value), 1));
|
|
32
|
+
const innerWidth = $derived(width - padding.left - padding.right);
|
|
33
|
+
const innerHeight = $derived(height - padding.top - padding.bottom);
|
|
34
|
+
|
|
35
|
+
const coords = $derived.by(() =>
|
|
36
|
+
data.map((point, index) => {
|
|
37
|
+
const x = padding.left + (data.length <= 1 ? innerWidth / 2 : (index / (data.length - 1)) * innerWidth);
|
|
38
|
+
const y = padding.top + innerHeight - (point.value / maxValue) * innerHeight;
|
|
39
|
+
return { ...point, x, y };
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const linePath = $derived(coords.map((point) => `${point.x},${point.y}`).join(' '));
|
|
44
|
+
const areaPath = $derived(
|
|
45
|
+
coords.length
|
|
46
|
+
? `M ${coords[0]?.x ?? 0} ${padding.top + innerHeight} L ${linePath.replace(/ /gu, ' L ')} L ${coords.at(-1)?.x ?? 0} ${padding.top + innerHeight} Z`
|
|
47
|
+
: '',
|
|
48
|
+
);
|
|
49
|
+
const gridLines = $derived([0, 0.25, 0.5, 0.75, 1]);
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<div class={cn('svadmin-area-chart', className)} data-slot="area-chart">
|
|
53
|
+
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Area chart" preserveAspectRatio="none">
|
|
54
|
+
{#each gridLines as ratio (ratio)}
|
|
55
|
+
{@const y = padding.top + innerHeight * ratio}
|
|
56
|
+
<line x1={padding.left} y1={y} x2={width - padding.right} y2={y} stroke="var(--border, currentColor)" stroke-width="1" opacity="0.6" />
|
|
57
|
+
<text x={padding.left - 6} y={y + 3} text-anchor="end" fill="var(--muted-foreground, currentColor)" font-size="10">{Math.round(maxValue * (1 - ratio))}</text>
|
|
58
|
+
{/each}
|
|
59
|
+
|
|
60
|
+
{#if fill && areaPath}
|
|
61
|
+
<path d={areaPath} fill="var(--primary, currentColor)" opacity="0.16" />
|
|
62
|
+
{/if}
|
|
63
|
+
|
|
64
|
+
{#if linePath}
|
|
65
|
+
<polyline points={linePath} fill="none" stroke="var(--primary, currentColor)" stroke-width={strokeWidth} stroke-linejoin="round" stroke-linecap="round" />
|
|
66
|
+
{/if}
|
|
67
|
+
|
|
68
|
+
{#each coords as point, index (index)}
|
|
69
|
+
<circle cx={point.x} cy={point.y} r="3" fill={point.color ?? 'var(--primary, currentColor)'} />
|
|
70
|
+
<text x={point.x} y={height - 8} text-anchor="middle" fill="var(--muted-foreground, currentColor)" font-size="10">{point.label}</text>
|
|
71
|
+
{/each}
|
|
72
|
+
</svg>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
.svadmin-area-chart { width: 100%; color: var(--foreground, currentColor); }
|
|
77
|
+
.svadmin-area-chart svg { display: block; width: 100%; height: auto; }
|
|
78
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface AreaPoint {
|
|
2
|
+
label: string;
|
|
3
|
+
value: number;
|
|
4
|
+
color?: string;
|
|
5
|
+
}
|
|
6
|
+
interface Props {
|
|
7
|
+
data: AreaPoint[];
|
|
8
|
+
height?: number;
|
|
9
|
+
/** Stroke width of the value line. */
|
|
10
|
+
strokeWidth?: number;
|
|
11
|
+
/** Fills the area under the line. Defaults to `true`. */
|
|
12
|
+
fill?: boolean;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
declare const AreaChart: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type AreaChart = ReturnType<typeof AreaChart>;
|
|
17
|
+
export default AreaChart;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../../utils.js';
|
|
3
|
+
|
|
4
|
+
interface ScatterPoint {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
label?: string;
|
|
8
|
+
color?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
data: ScatterPoint[];
|
|
13
|
+
height?: number;
|
|
14
|
+
pointRadius?: number;
|
|
15
|
+
xLabel?: string;
|
|
16
|
+
yLabel?: string;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
data,
|
|
22
|
+
height = 220,
|
|
23
|
+
pointRadius = 4,
|
|
24
|
+
xLabel,
|
|
25
|
+
yLabel,
|
|
26
|
+
class: className = '',
|
|
27
|
+
}: Props = $props();
|
|
28
|
+
|
|
29
|
+
const width = 480;
|
|
30
|
+
const padding = { top: 16, right: 16, bottom: 32, left: 40 };
|
|
31
|
+
const innerWidth = $derived(width - padding.left - padding.right);
|
|
32
|
+
const innerHeight = $derived(height - padding.top - padding.bottom);
|
|
33
|
+
|
|
34
|
+
const bounds = $derived.by(() => {
|
|
35
|
+
const xs = data.map((point) => point.x);
|
|
36
|
+
const ys = data.map((point) => point.y);
|
|
37
|
+
const minX = Math.min(...xs, 0);
|
|
38
|
+
const maxX = Math.max(...xs, 1);
|
|
39
|
+
const minY = Math.min(...ys, 0);
|
|
40
|
+
const maxY = Math.max(...ys, 1);
|
|
41
|
+
return {
|
|
42
|
+
minX,
|
|
43
|
+
maxX: maxX === minX ? minX + 1 : maxX,
|
|
44
|
+
minY,
|
|
45
|
+
maxY: maxY === minY ? minY + 1 : maxY,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function scaleX(value: number): number {
|
|
50
|
+
return padding.left + ((value - bounds.minX) / (bounds.maxX - bounds.minX)) * innerWidth;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scaleY(value: number): number {
|
|
54
|
+
return padding.top + innerHeight - ((value - bounds.minY) / (bounds.maxY - bounds.minY)) * innerHeight;
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<div class={cn('svadmin-scatter-chart', className)} data-slot="scatter-chart">
|
|
59
|
+
<svg
|
|
60
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
61
|
+
role="img"
|
|
62
|
+
aria-label={[yLabel ?? 'Y', xLabel ?? 'X'].join(' by ')}
|
|
63
|
+
preserveAspectRatio="none"
|
|
64
|
+
>
|
|
65
|
+
<line x1={padding.left} y1={padding.top + innerHeight} x2={width - padding.right} y2={padding.top + innerHeight} stroke="var(--border, currentColor)" />
|
|
66
|
+
<line x1={padding.left} y1={padding.top} x2={padding.left} y2={padding.top + innerHeight} stroke="var(--border, currentColor)" />
|
|
67
|
+
|
|
68
|
+
{#if xLabel}<text x={width - padding.right} y={height - 6} text-anchor="end" fill="var(--muted-foreground, currentColor)" font-size="10">{xLabel}</text>{/if}
|
|
69
|
+
{#if yLabel}<text x={padding.left - 6} y={padding.top + 8} text-anchor="end" fill="var(--muted-foreground, currentColor)" font-size="10">{yLabel}</text>{/if}
|
|
70
|
+
|
|
71
|
+
{#each data as point, index (index)}
|
|
72
|
+
<circle cx={scaleX(point.x)} cy={scaleY(point.y)} r={pointRadius} fill={point.color ?? 'var(--primary, currentColor)'} opacity="0.85">
|
|
73
|
+
<title>{point.label ?? `${point.x}, ${point.y}`}</title>
|
|
74
|
+
</circle>
|
|
75
|
+
{/each}
|
|
76
|
+
</svg>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<style>
|
|
80
|
+
.svadmin-scatter-chart { width: 100%; color: var(--foreground, currentColor); }
|
|
81
|
+
.svadmin-scatter-chart svg { display: block; width: 100%; height: auto; }
|
|
82
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface ScatterPoint {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
label?: string;
|
|
5
|
+
color?: string;
|
|
6
|
+
}
|
|
7
|
+
interface Props {
|
|
8
|
+
data: ScatterPoint[];
|
|
9
|
+
height?: number;
|
|
10
|
+
pointRadius?: number;
|
|
11
|
+
xLabel?: string;
|
|
12
|
+
yLabel?: string;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
declare const ScatterChart: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type ScatterChart = ReturnType<typeof ScatterChart>;
|
|
17
|
+
export default ScatterChart;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { default as AreaChart } from './AreaChart.svelte';
|
|
1
2
|
export { default as BarChart } from './BarChart.svelte';
|
|
2
3
|
export { default as LineChart } from './LineChart.svelte';
|
|
3
4
|
export { default as PieChart } from './PieChart.svelte';
|
|
5
|
+
export { default as ScatterChart } from './ScatterChart.svelte';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { default as AreaChart } from './AreaChart.svelte';
|
|
1
2
|
export { default as BarChart } from './BarChart.svelte';
|
|
2
3
|
export { default as LineChart } from './LineChart.svelte';
|
|
3
4
|
export { default as PieChart } from './PieChart.svelte';
|
|
5
|
+
export { default as ScatterChart } from './ScatterChart.svelte';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeMirror language presets for `@svadmin/ui/code-editor`.
|
|
3
|
+
*
|
|
4
|
+
* Each language is an optional peer dependency. Import this module only if you
|
|
5
|
+
* need the presets, and install the languages you actually use:
|
|
6
|
+
*
|
|
7
|
+
* ```bash
|
|
8
|
+
* bun add @codemirror/lang-json
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export { json } from '@codemirror/lang-json';
|
|
12
|
+
export { javascript, javascriptLanguage, typescriptLanguage, jsxLanguage, tsxLanguage, } from '@codemirror/lang-javascript';
|
|
13
|
+
export { sql } from '@codemirror/lang-sql';
|
|
14
|
+
export { markdown } from '@codemirror/lang-markdown';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeMirror language presets for `@svadmin/ui/code-editor`.
|
|
3
|
+
*
|
|
4
|
+
* Each language is an optional peer dependency. Import this module only if you
|
|
5
|
+
* need the presets, and install the languages you actually use:
|
|
6
|
+
*
|
|
7
|
+
* ```bash
|
|
8
|
+
* bun add @codemirror/lang-json
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export { json } from '@codemirror/lang-json';
|
|
12
|
+
export { javascript, javascriptLanguage, typescriptLanguage, jsxLanguage, tsxLanguage, } from '@codemirror/lang-javascript';
|
|
13
|
+
export { sql } from '@codemirror/lang-sql';
|
|
14
|
+
export { markdown } from '@codemirror/lang-markdown';
|
|
@@ -8,13 +8,27 @@
|
|
|
8
8
|
description?: string;
|
|
9
9
|
breadcrumbs?: string[];
|
|
10
10
|
actions?: Snippet;
|
|
11
|
+
density?: 'compact' | 'comfortable';
|
|
11
12
|
class?: string;
|
|
12
13
|
}
|
|
13
|
-
let {
|
|
14
|
-
|
|
14
|
+
let {
|
|
15
|
+
title,
|
|
16
|
+
eyebrow,
|
|
17
|
+
description,
|
|
18
|
+
breadcrumbs = [],
|
|
19
|
+
actions,
|
|
20
|
+
density = 'comfortable',
|
|
21
|
+
class: className = '',
|
|
22
|
+
}: Props = $props();
|
|
23
|
+
const styles = $derived(contentHeader({ density }));
|
|
15
24
|
</script>
|
|
16
25
|
|
|
17
|
-
<header
|
|
26
|
+
<header
|
|
27
|
+
class={styles.root + ' ' + className}
|
|
28
|
+
data-svadmin-page-header
|
|
29
|
+
data-svadmin-content-header
|
|
30
|
+
data-density={density}
|
|
31
|
+
>
|
|
18
32
|
{#if breadcrumbs.length > 0}
|
|
19
33
|
<nav aria-label="Breadcrumb" class={styles.breadcrumbs}>
|
|
20
34
|
{#each breadcrumbs as crumb, index (crumb)}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
children: Snippet;
|
|
13
13
|
pageId?: string;
|
|
14
14
|
width?: 'narrow' | 'default' | 'wide';
|
|
15
|
+
density?: 'compact' | 'comfortable';
|
|
15
16
|
class?: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -23,16 +24,24 @@
|
|
|
23
24
|
children,
|
|
24
25
|
pageId = 'content',
|
|
25
26
|
width = 'default',
|
|
27
|
+
density = 'comfortable',
|
|
26
28
|
class: className = '',
|
|
27
29
|
}: Props = $props();
|
|
28
30
|
|
|
29
31
|
// 保留旧组件对非窄屏/宽屏输入的默认宽度回退。
|
|
32
|
+
const resolvedWidth = $derived(width === 'narrow' || width === 'wide' ? width : 'default');
|
|
30
33
|
const styles = $derived(contentPage({
|
|
31
|
-
width:
|
|
34
|
+
width: resolvedWidth,
|
|
35
|
+
density,
|
|
32
36
|
}));
|
|
33
37
|
</script>
|
|
34
38
|
|
|
35
|
-
<div
|
|
36
|
-
{
|
|
39
|
+
<div
|
|
40
|
+
data-svadmin-content-page={pageId}
|
|
41
|
+
data-svadmin-content-page-width={resolvedWidth}
|
|
42
|
+
data-density={density}
|
|
43
|
+
class={styles.root + ' ' + className}
|
|
44
|
+
>
|
|
45
|
+
{#if title}<ContentPageHeader {title} {density} {...definedOptions({ "eyebrow": eyebrow })} {...definedOptions({ "description": description })} {...definedOptions({ "actions": actions })} />{/if}
|
|
37
46
|
{@render children()}
|
|
38
47
|
</div>
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
{#if loading}
|
|
36
36
|
<Skeleton class={styles.skeleton} />
|
|
37
37
|
{:else}
|
|
38
|
-
<p class={styles.value}>{value}</p>
|
|
38
|
+
<p data-svadmin-metric-value class={styles.value}>{value}</p>
|
|
39
39
|
{#if detail || trend}<div class={styles.meta}>{#if trend}<span class={styles.trend}>{trend}</span>{/if}{#if detail}<span class={styles.detail}>{detail}</span>{/if}</div>{/if}
|
|
40
40
|
{/if}
|
|
41
41
|
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface HighlightSegment {
|
|
2
|
+
text: string;
|
|
3
|
+
match: boolean;
|
|
4
|
+
}
|
|
5
|
+
export interface SplitHighlightsOptions {
|
|
6
|
+
caseSensitive?: boolean;
|
|
7
|
+
/** Requires the match to be surrounded by non-word characters. */
|
|
8
|
+
wholeWord?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function escapeRegExp(value: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Splits `text` into ordered segments, flagging the ones that match any query
|
|
13
|
+
* term. Overlapping matches are merged so the same characters are highlighted
|
|
14
|
+
* once. Never returns HTML, so hosts can render segments safely.
|
|
15
|
+
*/
|
|
16
|
+
export declare function splitHighlights(text: string, query: string | readonly string[] | undefined, options?: SplitHighlightsOptions): HighlightSegment[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/gu;
|
|
2
|
+
export function escapeRegExp(value) {
|
|
3
|
+
return value.replace(REGEXP_SPECIALS, '\\$&');
|
|
4
|
+
}
|
|
5
|
+
function normalizeTerms(query) {
|
|
6
|
+
if (query === undefined || query === null)
|
|
7
|
+
return [];
|
|
8
|
+
const list = typeof query === 'string' ? [query] : [...query];
|
|
9
|
+
return list
|
|
10
|
+
.map((term) => term.trim())
|
|
11
|
+
.filter((term) => term.length > 0);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Splits `text` into ordered segments, flagging the ones that match any query
|
|
15
|
+
* term. Overlapping matches are merged so the same characters are highlighted
|
|
16
|
+
* once. Never returns HTML, so hosts can render segments safely.
|
|
17
|
+
*/
|
|
18
|
+
export function splitHighlights(text, query, options = {}) {
|
|
19
|
+
if (text.length === 0)
|
|
20
|
+
return [];
|
|
21
|
+
const terms = normalizeTerms(query);
|
|
22
|
+
if (terms.length === 0)
|
|
23
|
+
return [{ text, match: false }];
|
|
24
|
+
const flags = options.caseSensitive ? 'gu' : 'giu';
|
|
25
|
+
const wordBoundary = options.wholeWord ? '\\b' : '';
|
|
26
|
+
const pattern = terms
|
|
27
|
+
.sort((a, b) => b.length - a.length)
|
|
28
|
+
.map((term) => `${wordBoundary}${escapeRegExp(term)}${wordBoundary}`)
|
|
29
|
+
.join('|');
|
|
30
|
+
let regex;
|
|
31
|
+
try {
|
|
32
|
+
regex = new RegExp(`(${pattern})`, flags);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [{ text, match: false }];
|
|
36
|
+
}
|
|
37
|
+
const matches = [];
|
|
38
|
+
for (const match of text.matchAll(regex)) {
|
|
39
|
+
const start = match.index;
|
|
40
|
+
const value = match[0];
|
|
41
|
+
if (start === undefined || value.length === 0)
|
|
42
|
+
continue;
|
|
43
|
+
matches.push({ start, end: start + value.length });
|
|
44
|
+
}
|
|
45
|
+
if (matches.length === 0)
|
|
46
|
+
return [{ text, match: false }];
|
|
47
|
+
const merged = [];
|
|
48
|
+
for (const current of matches) {
|
|
49
|
+
const previous = merged.at(-1);
|
|
50
|
+
if (previous && current.start <= previous.end) {
|
|
51
|
+
previous.end = Math.max(previous.end, current.end);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
merged.push({ ...current });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const segments = [];
|
|
58
|
+
let cursor = 0;
|
|
59
|
+
for (const { start, end } of merged) {
|
|
60
|
+
if (start > cursor)
|
|
61
|
+
segments.push({ text: text.slice(cursor, start), match: false });
|
|
62
|
+
segments.push({ text: text.slice(start, end), match: true });
|
|
63
|
+
cursor = end;
|
|
64
|
+
}
|
|
65
|
+
if (cursor < text.length)
|
|
66
|
+
segments.push({ text: text.slice(cursor), match: false });
|
|
67
|
+
return segments;
|
|
68
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface MediaQueryState {
|
|
2
|
+
readonly matches: boolean;
|
|
3
|
+
}
|
|
4
|
+
export interface DeviceDetectState {
|
|
5
|
+
readonly isMobile: boolean;
|
|
6
|
+
readonly isTablet: boolean;
|
|
7
|
+
readonly isDesktop: boolean;
|
|
8
|
+
readonly isTouch: boolean;
|
|
9
|
+
readonly prefersReducedMotion: boolean;
|
|
10
|
+
readonly prefersDark: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare const MOBILE_QUERY = "(max-width: 640px)";
|
|
13
|
+
export declare const TABLET_QUERY = "(min-width: 641px) and (max-width: 1024px)";
|
|
14
|
+
export declare const TOUCH_QUERY = "(hover: none), (pointer: coarse)";
|
|
15
|
+
export declare const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
|
16
|
+
export declare const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
17
|
+
/**
|
|
18
|
+
* Reactive `window.matchMedia`. Must be called during component initialisation
|
|
19
|
+
* because it registers a scoped effect. SSR renders the fallback (no match)
|
|
20
|
+
* and hydration corrects the value.
|
|
21
|
+
*/
|
|
22
|
+
export declare function useMediaQuery(query: string, fallback?: boolean): MediaQueryState;
|
|
23
|
+
/**
|
|
24
|
+
* Convenience device/preference detection built on `useMediaQuery`. Falls back
|
|
25
|
+
* to the desktop, motion-enabled profile during SSR.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useDeviceDetect(): DeviceDetectState;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const MOBILE_QUERY = '(max-width: 640px)';
|
|
2
|
+
export const TABLET_QUERY = '(min-width: 641px) and (max-width: 1024px)';
|
|
3
|
+
export const TOUCH_QUERY = '(hover: none), (pointer: coarse)';
|
|
4
|
+
export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
|
5
|
+
export const DARK_QUERY = '(prefers-color-scheme: dark)';
|
|
6
|
+
/**
|
|
7
|
+
* Reactive `window.matchMedia`. Must be called during component initialisation
|
|
8
|
+
* because it registers a scoped effect. SSR renders the fallback (no match)
|
|
9
|
+
* and hydration corrects the value.
|
|
10
|
+
*/
|
|
11
|
+
export function useMediaQuery(query, fallback = false) {
|
|
12
|
+
let matches = $state(fallback);
|
|
13
|
+
$effect(() => {
|
|
14
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
15
|
+
return;
|
|
16
|
+
const mediaQuery = window.matchMedia(query);
|
|
17
|
+
matches = mediaQuery.matches;
|
|
18
|
+
const onChange = (event) => {
|
|
19
|
+
matches = event.matches;
|
|
20
|
+
};
|
|
21
|
+
mediaQuery.addEventListener('change', onChange);
|
|
22
|
+
return () => mediaQuery.removeEventListener('change', onChange);
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
get matches() {
|
|
26
|
+
return matches;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Convenience device/preference detection built on `useMediaQuery`. Falls back
|
|
32
|
+
* to the desktop, motion-enabled profile during SSR.
|
|
33
|
+
*/
|
|
34
|
+
export function useDeviceDetect() {
|
|
35
|
+
const mobile = useMediaQuery(MOBILE_QUERY);
|
|
36
|
+
const tablet = useMediaQuery(TABLET_QUERY);
|
|
37
|
+
const touch = useMediaQuery(TOUCH_QUERY);
|
|
38
|
+
const reducedMotion = useMediaQuery(REDUCED_MOTION_QUERY);
|
|
39
|
+
const dark = useMediaQuery(DARK_QUERY);
|
|
40
|
+
return {
|
|
41
|
+
get isMobile() {
|
|
42
|
+
return mobile.matches;
|
|
43
|
+
},
|
|
44
|
+
get isTablet() {
|
|
45
|
+
return tablet.matches;
|
|
46
|
+
},
|
|
47
|
+
get isDesktop() {
|
|
48
|
+
return !mobile.matches && !tablet.matches;
|
|
49
|
+
},
|
|
50
|
+
get isTouch() {
|
|
51
|
+
return touch.matches;
|
|
52
|
+
},
|
|
53
|
+
get prefersReducedMotion() {
|
|
54
|
+
return reducedMotion.matches;
|
|
55
|
+
},
|
|
56
|
+
get prefersDark() {
|
|
57
|
+
return dark.matches;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface PhoneDialCode {
|
|
2
|
+
/** ISO 3166-1 alpha-2 country code. */
|
|
3
|
+
country: string;
|
|
4
|
+
/** International dialling prefix without the leading `+`. */
|
|
5
|
+
dial: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
/** Curated dialling codes covering common enterprise locales; hosts can pass their own list. */
|
|
9
|
+
export declare const PHONE_DIAL_CODES: readonly PhoneDialCode[];
|
|
10
|
+
export declare function findDialCodeByCountry(country: string, codes?: readonly PhoneDialCode[]): PhoneDialCode | undefined;
|
|
11
|
+
/** Builds an E.164 string (`+<dial><national>`), or an empty string when there is no number. */
|
|
12
|
+
export declare function toE164(country: string, nationalNumber: string, codes?: readonly PhoneDialCode[]): string;
|
|
13
|
+
/** Longest-prefix match so `380` wins over `1` for shared dial prefixes. */
|
|
14
|
+
export declare function splitE164(value: string, fallbackCountry?: string, codes?: readonly PhoneDialCode[]): {
|
|
15
|
+
country: string;
|
|
16
|
+
nationalNumber: string;
|
|
17
|
+
};
|
|
18
|
+
/** Formats a national number in loose groups of 3–4 digits for readability. */
|
|
19
|
+
export declare function formatNationalNumber(value: string): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Curated dialling codes covering common enterprise locales; hosts can pass their own list. */
|
|
2
|
+
export const PHONE_DIAL_CODES = [
|
|
3
|
+
{ country: 'AR', dial: '54', name: 'Argentina' },
|
|
4
|
+
{ country: 'AU', dial: '61', name: 'Australia' },
|
|
5
|
+
{ country: 'AT', dial: '43', name: 'Austria' },
|
|
6
|
+
{ country: 'BH', dial: '973', name: 'Bahrain' },
|
|
7
|
+
{ country: 'BD', dial: '880', name: 'Bangladesh' },
|
|
8
|
+
{ country: 'BE', dial: '32', name: 'Belgium' },
|
|
9
|
+
{ country: 'BR', dial: '55', name: 'Brazil' },
|
|
10
|
+
{ country: 'BG', dial: '359', name: 'Bulgaria' },
|
|
11
|
+
{ country: 'CA', dial: '1', name: 'Canada' },
|
|
12
|
+
{ country: 'CL', dial: '56', name: 'Chile' },
|
|
13
|
+
{ country: 'CN', dial: '86', name: 'China' },
|
|
14
|
+
{ country: 'CO', dial: '57', name: 'Colombia' },
|
|
15
|
+
{ country: 'HR', dial: '385', name: 'Croatia' },
|
|
16
|
+
{ country: 'CZ', dial: '420', name: 'Czechia' },
|
|
17
|
+
{ country: 'DK', dial: '45', name: 'Denmark' },
|
|
18
|
+
{ country: 'EG', dial: '20', name: 'Egypt' },
|
|
19
|
+
{ country: 'EE', dial: '372', name: 'Estonia' },
|
|
20
|
+
{ country: 'FI', dial: '358', name: 'Finland' },
|
|
21
|
+
{ country: 'FR', dial: '33', name: 'France' },
|
|
22
|
+
{ country: 'DE', dial: '49', name: 'Germany' },
|
|
23
|
+
{ country: 'GR', dial: '30', name: 'Greece' },
|
|
24
|
+
{ country: 'HK', dial: '852', name: 'Hong Kong' },
|
|
25
|
+
{ country: 'HU', dial: '36', name: 'Hungary' },
|
|
26
|
+
{ country: 'IS', dial: '354', name: 'Iceland' },
|
|
27
|
+
{ country: 'IN', dial: '91', name: 'India' },
|
|
28
|
+
{ country: 'ID', dial: '62', name: 'Indonesia' },
|
|
29
|
+
{ country: 'IE', dial: '353', name: 'Ireland' },
|
|
30
|
+
{ country: 'IL', dial: '972', name: 'Israel' },
|
|
31
|
+
{ country: 'IT', dial: '39', name: 'Italy' },
|
|
32
|
+
{ country: 'JP', dial: '81', name: 'Japan' },
|
|
33
|
+
{ country: 'JO', dial: '962', name: 'Jordan' },
|
|
34
|
+
{ country: 'KZ', dial: '7', name: 'Kazakhstan' },
|
|
35
|
+
{ country: 'KE', dial: '254', name: 'Kenya' },
|
|
36
|
+
{ country: 'KW', dial: '965', name: 'Kuwait' },
|
|
37
|
+
{ country: 'LV', dial: '371', name: 'Latvia' },
|
|
38
|
+
{ country: 'LT', dial: '370', name: 'Lithuania' },
|
|
39
|
+
{ country: 'LU', dial: '352', name: 'Luxembourg' },
|
|
40
|
+
{ country: 'MY', dial: '60', name: 'Malaysia' },
|
|
41
|
+
{ country: 'MT', dial: '356', name: 'Malta' },
|
|
42
|
+
{ country: 'MX', dial: '52', name: 'Mexico' },
|
|
43
|
+
{ country: 'MA', dial: '212', name: 'Morocco' },
|
|
44
|
+
{ country: 'NL', dial: '31', name: 'Netherlands' },
|
|
45
|
+
{ country: 'NZ', dial: '64', name: 'New Zealand' },
|
|
46
|
+
{ country: 'NG', dial: '234', name: 'Nigeria' },
|
|
47
|
+
{ country: 'NO', dial: '47', name: 'Norway' },
|
|
48
|
+
{ country: 'OM', dial: '968', name: 'Oman' },
|
|
49
|
+
{ country: 'PK', dial: '92', name: 'Pakistan' },
|
|
50
|
+
{ country: 'PE', dial: '51', name: 'Peru' },
|
|
51
|
+
{ country: 'PH', dial: '63', name: 'Philippines' },
|
|
52
|
+
{ country: 'PL', dial: '48', name: 'Poland' },
|
|
53
|
+
{ country: 'PT', dial: '351', name: 'Portugal' },
|
|
54
|
+
{ country: 'QA', dial: '974', name: 'Qatar' },
|
|
55
|
+
{ country: 'RO', dial: '40', name: 'Romania' },
|
|
56
|
+
{ country: 'RU', dial: '7', name: 'Russia' },
|
|
57
|
+
{ country: 'SA', dial: '966', name: 'Saudi Arabia' },
|
|
58
|
+
{ country: 'RS', dial: '381', name: 'Serbia' },
|
|
59
|
+
{ country: 'SG', dial: '65', name: 'Singapore' },
|
|
60
|
+
{ country: 'SK', dial: '421', name: 'Slovakia' },
|
|
61
|
+
{ country: 'SI', dial: '386', name: 'Slovenia' },
|
|
62
|
+
{ country: 'ZA', dial: '27', name: 'South Africa' },
|
|
63
|
+
{ country: 'KR', dial: '82', name: 'South Korea' },
|
|
64
|
+
{ country: 'ES', dial: '34', name: 'Spain' },
|
|
65
|
+
{ country: 'LK', dial: '94', name: 'Sri Lanka' },
|
|
66
|
+
{ country: 'SE', dial: '46', name: 'Sweden' },
|
|
67
|
+
{ country: 'CH', dial: '41', name: 'Switzerland' },
|
|
68
|
+
{ country: 'TW', dial: '886', name: 'Taiwan' },
|
|
69
|
+
{ country: 'TH', dial: '66', name: 'Thailand' },
|
|
70
|
+
{ country: 'TR', dial: '90', name: 'Türkiye' },
|
|
71
|
+
{ country: 'UA', dial: '380', name: 'Ukraine' },
|
|
72
|
+
{ country: 'AE', dial: '971', name: 'United Arab Emirates' },
|
|
73
|
+
{ country: 'GB', dial: '44', name: 'United Kingdom' },
|
|
74
|
+
{ country: 'US', dial: '1', name: 'United States' },
|
|
75
|
+
{ country: 'UY', dial: '598', name: 'Uruguay' },
|
|
76
|
+
{ country: 'UZ', dial: '998', name: 'Uzbekistan' },
|
|
77
|
+
{ country: 'VE', dial: '58', name: 'Venezuela' },
|
|
78
|
+
{ country: 'VN', dial: '84', name: 'Vietnam' },
|
|
79
|
+
];
|
|
80
|
+
export function findDialCodeByCountry(country, codes = PHONE_DIAL_CODES) {
|
|
81
|
+
const target = country.trim().toUpperCase();
|
|
82
|
+
return codes.find((entry) => entry.country === target);
|
|
83
|
+
}
|
|
84
|
+
const digitsOnly = (value) => value.replace(/\D+/gu, '');
|
|
85
|
+
/** Builds an E.164 string (`+<dial><national>`), or an empty string when there is no number. */
|
|
86
|
+
export function toE164(country, nationalNumber, codes = PHONE_DIAL_CODES) {
|
|
87
|
+
const national = digitsOnly(nationalNumber);
|
|
88
|
+
if (national.length === 0)
|
|
89
|
+
return '';
|
|
90
|
+
const entry = findDialCodeByCountry(country, codes);
|
|
91
|
+
if (!entry)
|
|
92
|
+
return `+${national}`;
|
|
93
|
+
return `+${entry.dial}${national.replace(/^0+/u, '')}`;
|
|
94
|
+
}
|
|
95
|
+
/** Longest-prefix match so `380` wins over `1` for shared dial prefixes. */
|
|
96
|
+
export function splitE164(value, fallbackCountry = 'CN', codes = PHONE_DIAL_CODES) {
|
|
97
|
+
const digits = digitsOnly(value);
|
|
98
|
+
if (digits.length === 0)
|
|
99
|
+
return { country: fallbackCountry, nationalNumber: '' };
|
|
100
|
+
const byDialLength = [...codes].sort((a, b) => b.dial.length - a.dial.length);
|
|
101
|
+
for (const entry of byDialLength) {
|
|
102
|
+
if (digits.startsWith(entry.dial)) {
|
|
103
|
+
return { country: entry.country, nationalNumber: digits.slice(entry.dial.length) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { country: fallbackCountry, nationalNumber: digits };
|
|
107
|
+
}
|
|
108
|
+
/** Formats a national number in loose groups of 3–4 digits for readability. */
|
|
109
|
+
export function formatNationalNumber(value) {
|
|
110
|
+
const digits = digitsOnly(value);
|
|
111
|
+
return digits.replace(/(\d{3,4})(?=\d)/gu, '$1 ').trim();
|
|
112
|
+
}
|