@astronox/core 0.1.0 → 0.2.1
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/README.md +15 -27
- package/package.json +3 -1
- package/src/components/actions/MediaTrigger/MediaTrigger.astro +12 -4
- package/src/components/actions/MediaTrigger/MediaTrigger.css +1 -0
- package/src/components/content/MarkdownContent/MarkdownContent.css +0 -1
- package/src/components/content/MarkdownContent/MarkdownNode.astro +1 -0
- package/src/components/forms/Checkbox/Checkbox.astro +60 -0
- package/src/components/forms/Checkbox/Checkbox.css +126 -0
- package/src/components/forms/FieldGroup/FieldGroup.astro +34 -0
- package/src/components/forms/FieldGroup/FieldGroup.css +30 -0
- package/src/components/icons/CheckIcon.astro +22 -0
- package/src/components/index.ts +2 -0
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +2 -1
- package/src/components/navigation/SubNavigation/SubNavigation.astro +2 -1
- package/src/composition/Grid/Grid.astro +9 -0
- package/src/composition/Grid/Grid.css +52 -0
- package/src/composition/GridItem/GridItem.astro +9 -0
- package/src/composition/GridItem/GridItem.css +12 -0
- package/src/display/contracts.ts +1 -0
- package/src/display/index.ts +1 -0
- package/src/display/responsiveMedia.ts +38 -0
- package/src/i18n/LocalePreferenceBootstrap.astro +40 -0
- package/src/i18n/catalog.ts +203 -0
- package/src/i18n/config.ts +112 -0
- package/src/i18n/coreMessages.ts +37 -0
- package/src/i18n/formatters.ts +34 -0
- package/src/i18n/index.ts +6 -0
- package/src/i18n/preference.ts +58 -0
- package/src/index.ts +2 -0
- package/src/patterns/Combobox/comboboxController.ts +72 -0
- package/src/patterns/Table/Table.css +2 -1
- package/src/patterns/Table/TableBody.astro +3 -1
- package/src/patterns/Table/TableBody.css +6 -0
- package/src/patterns/Table/tableFilterController.ts +60 -2
- package/src/patterns/Table/tableLayout.ts +10 -3
- package/src/routing/index.ts +1 -0
- package/src/routing/localized.ts +155 -0
- package/src/seo/index.ts +1 -0
- package/src/seo/localizedMeta.ts +58 -0
- package/src/styles/style.css +2 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DisplayMedia } from './contracts';
|
|
2
|
+
|
|
3
|
+
export type MediaRendition = Pick<
|
|
4
|
+
DisplayMedia,
|
|
5
|
+
'contentBounds' | 'height' | 'src' | 'width'
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
export type ResponsiveMediaInput = {
|
|
9
|
+
alt: string;
|
|
10
|
+
loading?: DisplayMedia['loading'];
|
|
11
|
+
renditions: readonly MediaRendition[];
|
|
12
|
+
sizes: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function presentResponsiveMedia({
|
|
16
|
+
alt,
|
|
17
|
+
loading = 'lazy',
|
|
18
|
+
renditions,
|
|
19
|
+
sizes,
|
|
20
|
+
}: ResponsiveMediaInput): DisplayMedia {
|
|
21
|
+
const sortedRenditions = renditions.toSorted(
|
|
22
|
+
(left, right) => left.width - right.width,
|
|
23
|
+
);
|
|
24
|
+
const smallest = sortedRenditions[0];
|
|
25
|
+
if (!smallest) throw new Error(`Missing media rendition: ${alt}`);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
...smallest,
|
|
29
|
+
alt,
|
|
30
|
+
fit: 'contain',
|
|
31
|
+
loading,
|
|
32
|
+
sizes,
|
|
33
|
+
sources: sortedRenditions.map((rendition) => ({
|
|
34
|
+
src: rendition.src,
|
|
35
|
+
width: rendition.width,
|
|
36
|
+
})),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { LocaleDefinition } from './config';
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
defaultLocale: string;
|
|
6
|
+
locales: readonly LocaleDefinition[];
|
|
7
|
+
storageKey: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const { defaultLocale, locales, storageKey } = Astro.props;
|
|
11
|
+
const localeEntries = locales
|
|
12
|
+
.filter(({ id }) => id !== defaultLocale)
|
|
13
|
+
.map(({ id, urlCode }) => [id, urlCode]);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<script
|
|
17
|
+
is:inline
|
|
18
|
+
define:vars={{
|
|
19
|
+
localeEntries,
|
|
20
|
+
storageKey,
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
(() => {
|
|
24
|
+
if (window.location.pathname !== '/') return;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const preferredLocale = localStorage.getItem(storageKey);
|
|
28
|
+
const preferredUrlCode = localeEntries.find(
|
|
29
|
+
([locale]) => locale === preferredLocale,
|
|
30
|
+
)?.[1];
|
|
31
|
+
if (!preferredUrlCode) return;
|
|
32
|
+
|
|
33
|
+
window.location.replace(
|
|
34
|
+
`/${preferredUrlCode}${window.location.search}${window.location.hash}`,
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
// The canonical default-locale page remains usable without storage.
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
</script>
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
export type MessageTree = {
|
|
2
|
+
readonly [key: string]: string | MessageTree;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export type MessageShape<T> = {
|
|
6
|
+
readonly [Key in keyof T]: T[Key] extends string
|
|
7
|
+
? string
|
|
8
|
+
: T[Key] extends MessageTree
|
|
9
|
+
? MessageShape<T[Key]>
|
|
10
|
+
: never;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type VersionedMessageShape<
|
|
14
|
+
T extends { readonly schemaVersion: number },
|
|
15
|
+
> = {
|
|
16
|
+
readonly schemaVersion: T['schemaVersion'];
|
|
17
|
+
} & MessageShape<Omit<T, 'schemaVersion'>>;
|
|
18
|
+
|
|
19
|
+
export function defineLocalizedCatalog<
|
|
20
|
+
const TLocale extends string,
|
|
21
|
+
const TFallbackLocale extends TLocale,
|
|
22
|
+
const TFallbackCatalog extends MessageTree,
|
|
23
|
+
>({
|
|
24
|
+
catalogs,
|
|
25
|
+
fallbackLocale,
|
|
26
|
+
locales,
|
|
27
|
+
}: {
|
|
28
|
+
catalogs: Readonly<Record<TLocale, unknown>> &
|
|
29
|
+
Readonly<Record<TFallbackLocale, TFallbackCatalog>>;
|
|
30
|
+
fallbackLocale: TFallbackLocale;
|
|
31
|
+
locales: readonly TLocale[];
|
|
32
|
+
}): Readonly<Record<TLocale, MessageShape<TFallbackCatalog>>> {
|
|
33
|
+
const fallback = catalogs[fallbackLocale];
|
|
34
|
+
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
locales.map((locale) => [
|
|
37
|
+
locale,
|
|
38
|
+
resolveLocalizedCatalog(fallback, catalogs[locale], locale),
|
|
39
|
+
]),
|
|
40
|
+
) as Readonly<Record<TLocale, MessageShape<TFallbackCatalog>>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveLocalizedCatalog<const TFallback extends MessageTree>(
|
|
44
|
+
fallback: TFallback,
|
|
45
|
+
translated: unknown,
|
|
46
|
+
locale: string,
|
|
47
|
+
): MessageShape<TFallback> {
|
|
48
|
+
return resolveMessageNode(
|
|
49
|
+
fallback,
|
|
50
|
+
translated,
|
|
51
|
+
locale,
|
|
52
|
+
'',
|
|
53
|
+
) as MessageShape<TFallback>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveVersionedLocaleCatalog<
|
|
57
|
+
const TFallback extends { readonly schemaVersion: number },
|
|
58
|
+
>(
|
|
59
|
+
fallback: TFallback,
|
|
60
|
+
translated: unknown,
|
|
61
|
+
locale: string,
|
|
62
|
+
): VersionedMessageShape<TFallback> {
|
|
63
|
+
if (!isMessageRecord(translated)) {
|
|
64
|
+
throw new Error(`Invalid ${locale} locale catalog: expected object`);
|
|
65
|
+
}
|
|
66
|
+
if (translated.schemaVersion !== fallback.schemaVersion) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Invalid ${locale} locale schema version: ` +
|
|
69
|
+
`expected ${fallback.schemaVersion}, received ${String(translated.schemaVersion)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
schemaVersion: fallback.schemaVersion,
|
|
75
|
+
...resolveLocalizedCatalog(
|
|
76
|
+
stripSchemaVersion(fallback) as MessageTree,
|
|
77
|
+
stripSchemaVersion(translated),
|
|
78
|
+
locale,
|
|
79
|
+
),
|
|
80
|
+
} as VersionedMessageShape<TFallback>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatMessage(
|
|
84
|
+
template: string,
|
|
85
|
+
values: Readonly<Record<string, string | number>>,
|
|
86
|
+
): string {
|
|
87
|
+
const rendered = template.replace(
|
|
88
|
+
/\{([a-zA-Z][a-zA-Z0-9]*)\}/g,
|
|
89
|
+
(_, key: string) => {
|
|
90
|
+
const value = values[key];
|
|
91
|
+
if (value === undefined) {
|
|
92
|
+
throw new Error(`Missing localized message value: ${key}`);
|
|
93
|
+
}
|
|
94
|
+
return String(value);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (/\{[^{}]+\}/.test(rendered)) {
|
|
99
|
+
throw new Error(`Unresolved localized message: ${rendered}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return rendered;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveMessageNode(
|
|
106
|
+
fallback: string | MessageTree,
|
|
107
|
+
translated: unknown,
|
|
108
|
+
locale: string,
|
|
109
|
+
path: string,
|
|
110
|
+
): string | MessageTree {
|
|
111
|
+
if (typeof fallback === 'string') {
|
|
112
|
+
assertMessage(fallback, 'fallback', path);
|
|
113
|
+
if (translated === undefined) return fallback;
|
|
114
|
+
if (typeof translated !== 'string') {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Invalid ${locale} localized value at ${path}: expected string`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
assertMessage(translated, locale, path);
|
|
120
|
+
assertPlaceholders(fallback, translated, locale, path);
|
|
121
|
+
return translated;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (translated !== undefined && !isMessageRecord(translated)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Invalid ${locale} localized value at ${path || '<root>'}: expected object`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const translatedRecord = translated ?? {};
|
|
131
|
+
const extraKeys = Object.keys(translatedRecord).filter(
|
|
132
|
+
(key) => !(key in fallback),
|
|
133
|
+
);
|
|
134
|
+
if (extraKeys.length > 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Invalid ${locale} localized keys at ${path || '<root>'}: ` +
|
|
137
|
+
`[${extraKeys.join(', ')}]`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return Object.fromEntries(
|
|
142
|
+
Object.entries(fallback).map(([key, fallbackValue]) => {
|
|
143
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
144
|
+
return [
|
|
145
|
+
key,
|
|
146
|
+
resolveMessageNode(
|
|
147
|
+
fallbackValue,
|
|
148
|
+
translatedRecord[key],
|
|
149
|
+
locale,
|
|
150
|
+
childPath,
|
|
151
|
+
),
|
|
152
|
+
];
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isMessageRecord(value: unknown): value is Record<string, unknown> {
|
|
158
|
+
return (
|
|
159
|
+
typeof value === 'object' &&
|
|
160
|
+
value !== null &&
|
|
161
|
+
!Array.isArray(value) &&
|
|
162
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function stripSchemaVersion(
|
|
167
|
+
value: Readonly<Record<string, unknown>>,
|
|
168
|
+
): Record<string, unknown> {
|
|
169
|
+
return Object.fromEntries(
|
|
170
|
+
Object.entries(value).filter(([key]) => key !== 'schemaVersion'),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function assertMessage(value: string, locale: string, path: string): void {
|
|
175
|
+
if (value.trim().length === 0) {
|
|
176
|
+
throw new Error(`Empty ${locale} localized value at ${path}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function assertPlaceholders(
|
|
181
|
+
fallback: string,
|
|
182
|
+
translated: string,
|
|
183
|
+
locale: string,
|
|
184
|
+
path: string,
|
|
185
|
+
): void {
|
|
186
|
+
const expected = getPlaceholders(fallback);
|
|
187
|
+
const actual = getPlaceholders(translated);
|
|
188
|
+
if (
|
|
189
|
+
expected.length !== actual.length ||
|
|
190
|
+
expected.some((placeholder, index) => placeholder !== actual[index])
|
|
191
|
+
) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Invalid ${locale} placeholders for ${path}: ` +
|
|
194
|
+
`expected [${expected.join(', ')}], received [${actual.join(', ')}]`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getPlaceholders(message: string): readonly string[] {
|
|
200
|
+
return [...message.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)]
|
|
201
|
+
.map((match) => match[1]!)
|
|
202
|
+
.toSorted();
|
|
203
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export type TextDirection = 'ltr' | 'rtl';
|
|
2
|
+
|
|
3
|
+
export type LocaleDefinition = {
|
|
4
|
+
id: string;
|
|
5
|
+
urlCode: string;
|
|
6
|
+
languageTag: string;
|
|
7
|
+
nativeLabel: string;
|
|
8
|
+
intlLocale: string;
|
|
9
|
+
openGraphLocale: string;
|
|
10
|
+
direction: TextDirection;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type LocaleConfig<
|
|
14
|
+
TDefinition extends LocaleDefinition = LocaleDefinition,
|
|
15
|
+
> = {
|
|
16
|
+
definitions: readonly TDefinition[];
|
|
17
|
+
locales: readonly TDefinition['id'][];
|
|
18
|
+
localizedLocales: readonly TDefinition['id'][];
|
|
19
|
+
defaultLocale: TDefinition['id'];
|
|
20
|
+
prefixDefaultLocale: boolean;
|
|
21
|
+
isLocale(value: string): value is TDefinition['id'];
|
|
22
|
+
getDefinition(locale: TDefinition['id']): TDefinition;
|
|
23
|
+
getLocaleFromUrlCode(value: string): TDefinition['id'] | undefined;
|
|
24
|
+
getLocaleFromLanguageTag(value: string): TDefinition['id'] | undefined;
|
|
25
|
+
requireLocalizedLocale(value: string): TDefinition['id'];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function createLocaleConfig<
|
|
29
|
+
const TDefinitions extends readonly [
|
|
30
|
+
LocaleDefinition,
|
|
31
|
+
...LocaleDefinition[],
|
|
32
|
+
],
|
|
33
|
+
const TDefaultLocale extends TDefinitions[number]['id'],
|
|
34
|
+
>({
|
|
35
|
+
defaultLocale,
|
|
36
|
+
locales,
|
|
37
|
+
prefixDefaultLocale = false,
|
|
38
|
+
}: {
|
|
39
|
+
defaultLocale: TDefaultLocale;
|
|
40
|
+
locales: TDefinitions;
|
|
41
|
+
prefixDefaultLocale?: boolean;
|
|
42
|
+
}): LocaleConfig<TDefinitions[number]> & {
|
|
43
|
+
defaultLocale: TDefaultLocale;
|
|
44
|
+
} {
|
|
45
|
+
type Definition = TDefinitions[number];
|
|
46
|
+
type Locale = Definition['id'];
|
|
47
|
+
|
|
48
|
+
const localeById = new Map<Locale, Definition>();
|
|
49
|
+
const localeByUrlCode = new Map<string, Definition>();
|
|
50
|
+
const localeByLanguageTag = new Map<string, Definition>();
|
|
51
|
+
|
|
52
|
+
for (const locale of locales) {
|
|
53
|
+
if (localeById.has(locale.id)) {
|
|
54
|
+
throw new Error(`Duplicate locale: ${locale.id}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const normalizedUrlCode = locale.urlCode.toLowerCase();
|
|
58
|
+
if (localeByUrlCode.has(normalizedUrlCode)) {
|
|
59
|
+
throw new Error(`Duplicate locale URL code: ${locale.urlCode}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const normalizedLanguageTag = locale.languageTag.toLowerCase();
|
|
63
|
+
if (localeByLanguageTag.has(normalizedLanguageTag)) {
|
|
64
|
+
throw new Error(`Duplicate locale language tag: ${locale.languageTag}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
localeById.set(locale.id, locale);
|
|
68
|
+
localeByUrlCode.set(normalizedUrlCode, locale);
|
|
69
|
+
localeByLanguageTag.set(normalizedLanguageTag, locale);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!localeById.has(defaultLocale)) {
|
|
73
|
+
throw new Error(`Unknown default locale: ${defaultLocale}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const localeIds = locales.map(({ id }) => id);
|
|
77
|
+
const localizedLocales = localeIds.filter(
|
|
78
|
+
(locale) => prefixDefaultLocale || locale !== defaultLocale,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
definitions: locales,
|
|
83
|
+
locales: localeIds,
|
|
84
|
+
localizedLocales,
|
|
85
|
+
defaultLocale,
|
|
86
|
+
prefixDefaultLocale,
|
|
87
|
+
isLocale(value: string): value is Locale {
|
|
88
|
+
return localeById.has(value as Locale);
|
|
89
|
+
},
|
|
90
|
+
getDefinition(locale: Locale): Definition {
|
|
91
|
+
const definition = localeById.get(locale);
|
|
92
|
+
if (!definition) throw new Error(`Unsupported locale: ${locale}`);
|
|
93
|
+
return definition;
|
|
94
|
+
},
|
|
95
|
+
getLocaleFromUrlCode(value: string): Locale | undefined {
|
|
96
|
+
return localeByUrlCode.get(value.toLowerCase())?.id;
|
|
97
|
+
},
|
|
98
|
+
getLocaleFromLanguageTag(value: string): Locale | undefined {
|
|
99
|
+
return localeByLanguageTag.get(value.toLowerCase())?.id;
|
|
100
|
+
},
|
|
101
|
+
requireLocalizedLocale(value: string): Locale {
|
|
102
|
+
const locale = localeByUrlCode.get(value.toLowerCase())?.id;
|
|
103
|
+
if (
|
|
104
|
+
!locale ||
|
|
105
|
+
(!prefixDefaultLocale && locale === defaultLocale)
|
|
106
|
+
) {
|
|
107
|
+
throw new Error(`Unsupported localized route locale: ${value}`);
|
|
108
|
+
}
|
|
109
|
+
return locale;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveLocalizedCatalog,
|
|
3
|
+
type MessageShape,
|
|
4
|
+
} from './catalog';
|
|
5
|
+
|
|
6
|
+
export const defaultCoreMessages = {
|
|
7
|
+
actions: {
|
|
8
|
+
close: 'Close',
|
|
9
|
+
copy: 'Copy',
|
|
10
|
+
enterFullscreen: 'Enter full screen',
|
|
11
|
+
exitFullscreen: 'Exit full screen',
|
|
12
|
+
next: 'Next',
|
|
13
|
+
open: 'Open',
|
|
14
|
+
previous: 'Previous',
|
|
15
|
+
search: 'Search',
|
|
16
|
+
},
|
|
17
|
+
feedback: {
|
|
18
|
+
noResults: 'No results.',
|
|
19
|
+
},
|
|
20
|
+
navigation: {
|
|
21
|
+
breadcrumbs: 'Breadcrumbs',
|
|
22
|
+
section: 'Section navigation',
|
|
23
|
+
},
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export type CoreMessages = MessageShape<typeof defaultCoreMessages>;
|
|
27
|
+
|
|
28
|
+
export function resolveCoreMessages(
|
|
29
|
+
translated: unknown = undefined,
|
|
30
|
+
locale = 'en',
|
|
31
|
+
): CoreMessages {
|
|
32
|
+
return resolveLocalizedCatalog(
|
|
33
|
+
defaultCoreMessages,
|
|
34
|
+
translated,
|
|
35
|
+
locale,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type LocaleFormatters = ReturnType<typeof createLocaleFormatters>;
|
|
2
|
+
|
|
3
|
+
export function createLocaleFormatters(intlLocale: string) {
|
|
4
|
+
return {
|
|
5
|
+
locale: intlLocale,
|
|
6
|
+
collator: new Intl.Collator(intlLocale, { sensitivity: 'base' }),
|
|
7
|
+
integer: new Intl.NumberFormat(intlLocale, {
|
|
8
|
+
maximumFractionDigits: 0,
|
|
9
|
+
}),
|
|
10
|
+
decimal: new Intl.NumberFormat(intlLocale, {
|
|
11
|
+
maximumFractionDigits: 2,
|
|
12
|
+
}),
|
|
13
|
+
fixedDecimal: new Intl.NumberFormat(intlLocale, {
|
|
14
|
+
minimumFractionDigits: 2,
|
|
15
|
+
maximumFractionDigits: 2,
|
|
16
|
+
}),
|
|
17
|
+
dateLong: new Intl.DateTimeFormat(intlLocale, {
|
|
18
|
+
day: 'numeric',
|
|
19
|
+
month: 'short',
|
|
20
|
+
year: 'numeric',
|
|
21
|
+
timeZone: 'UTC',
|
|
22
|
+
}),
|
|
23
|
+
dateTimeLong: new Intl.DateTimeFormat(intlLocale, {
|
|
24
|
+
dateStyle: 'long',
|
|
25
|
+
timeStyle: 'short',
|
|
26
|
+
timeZone: 'UTC',
|
|
27
|
+
}),
|
|
28
|
+
list: new Intl.ListFormat(intlLocale, {
|
|
29
|
+
style: 'long',
|
|
30
|
+
type: 'conjunction',
|
|
31
|
+
}),
|
|
32
|
+
plural: new Intl.PluralRules(intlLocale),
|
|
33
|
+
} as const;
|
|
34
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type LocalePreferenceControllerOptions<TLocale extends string> = {
|
|
2
|
+
storageKey: string;
|
|
3
|
+
resolveLocale(languageTag: string): TLocale | undefined;
|
|
4
|
+
resolvePath(pathname: string, locale: TLocale): string;
|
|
5
|
+
initializedAttribute?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function createLocalePreferenceController<TLocale extends string>({
|
|
9
|
+
initializedAttribute = 'data-language-preference-initialized',
|
|
10
|
+
resolveLocale,
|
|
11
|
+
resolvePath,
|
|
12
|
+
storageKey,
|
|
13
|
+
}: LocalePreferenceControllerOptions<TLocale>) {
|
|
14
|
+
return function initLocalePreference(
|
|
15
|
+
root: Document = document,
|
|
16
|
+
): () => void {
|
|
17
|
+
const element = root.documentElement;
|
|
18
|
+
if (element.hasAttribute(initializedAttribute)) return () => undefined;
|
|
19
|
+
element.setAttribute(initializedAttribute, '');
|
|
20
|
+
|
|
21
|
+
const handleClick = (event: MouseEvent): void => {
|
|
22
|
+
const target = event.target;
|
|
23
|
+
if (!(target instanceof Element)) return;
|
|
24
|
+
|
|
25
|
+
const link = target.closest<HTMLAnchorElement>('a[hreflang]');
|
|
26
|
+
const languageTag = link?.getAttribute('hreflang');
|
|
27
|
+
const locale = languageTag
|
|
28
|
+
? resolveLocale(languageTag)
|
|
29
|
+
: undefined;
|
|
30
|
+
if (!locale) return;
|
|
31
|
+
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(storageKey, locale);
|
|
35
|
+
} catch {
|
|
36
|
+
// Navigation remains available when storage is unavailable.
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const destination = new URL(
|
|
40
|
+
resolvePath(window.location.pathname, locale),
|
|
41
|
+
window.location.origin,
|
|
42
|
+
);
|
|
43
|
+
destination.search = window.location.search;
|
|
44
|
+
destination.hash = window.location.hash;
|
|
45
|
+
|
|
46
|
+
if (destination.href !== window.location.href) {
|
|
47
|
+
window.location.assign(destination);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
root.addEventListener('click', handleClick);
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
root.removeEventListener('click', handleClick);
|
|
55
|
+
element.removeAttribute(initializedAttribute);
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,11 @@ type SetComboboxValueOptions = {
|
|
|
12
12
|
emit?: boolean;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
export type ComboboxNumberBounds = {
|
|
16
|
+
min: number;
|
|
17
|
+
max: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
15
20
|
type NumberBounds = {
|
|
16
21
|
min: number;
|
|
17
22
|
max: number;
|
|
@@ -93,6 +98,17 @@ function normalizeNumber(value: number, bounds: NumberBounds): number {
|
|
|
93
98
|
return Math.min(bounds.max, Math.max(bounds.min, stepped));
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
function syncNumberOptionBounds(
|
|
102
|
+
root: HTMLElement,
|
|
103
|
+
bounds: NumberBounds,
|
|
104
|
+
): void {
|
|
105
|
+
for (const option of getOptions(root)) {
|
|
106
|
+
const value = Number(option.dataset.comboboxOption);
|
|
107
|
+
option.hidden =
|
|
108
|
+
!Number.isFinite(value) || value < bounds.min || value > bounds.max;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
export function setComboboxValues(
|
|
97
113
|
root: HTMLElement,
|
|
98
114
|
requestedValues: readonly string[],
|
|
@@ -186,6 +202,43 @@ export function setComboboxNumberValue(
|
|
|
186
202
|
}
|
|
187
203
|
}
|
|
188
204
|
|
|
205
|
+
export function setComboboxNumberBounds(
|
|
206
|
+
root: HTMLElement,
|
|
207
|
+
{ min, max }: ComboboxNumberBounds,
|
|
208
|
+
): void {
|
|
209
|
+
if (root.dataset.comboboxMode !== 'number') return;
|
|
210
|
+
|
|
211
|
+
const currentBounds = getNumberBounds(root);
|
|
212
|
+
const optionValues = getOptions(root)
|
|
213
|
+
.map((option) => Number(option.dataset.comboboxOption))
|
|
214
|
+
.filter(Number.isFinite);
|
|
215
|
+
const renderedValues = new Set(optionValues);
|
|
216
|
+
if (
|
|
217
|
+
!currentBounds ||
|
|
218
|
+
!Number.isInteger(min) ||
|
|
219
|
+
!Number.isInteger(max) ||
|
|
220
|
+
min > max ||
|
|
221
|
+
!renderedValues.has(min) ||
|
|
222
|
+
!renderedValues.has(max)
|
|
223
|
+
) {
|
|
224
|
+
throw new RangeError(
|
|
225
|
+
'Combobox number bounds must be ordered rendered values aligned to the configured step',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
root.dataset.comboboxNumberMin = String(min);
|
|
230
|
+
root.dataset.comboboxNumberMax = String(max);
|
|
231
|
+
const bounds = getNumberBounds(root);
|
|
232
|
+
const input = root.querySelector<HTMLInputElement>(
|
|
233
|
+
'[data-combobox-number-input]',
|
|
234
|
+
);
|
|
235
|
+
if (!bounds || !input) return;
|
|
236
|
+
|
|
237
|
+
syncNumberOptionBounds(root, bounds);
|
|
238
|
+
setComboboxNumberValue(root, Number(input.value));
|
|
239
|
+
root.dispatchEvent(new Event('combobox:number-bounds-change'));
|
|
240
|
+
}
|
|
241
|
+
|
|
189
242
|
export function initCombobox(root: HTMLElement): () => void {
|
|
190
243
|
if (root.getAttribute(initializedAttribute) === 'true') return noop;
|
|
191
244
|
|
|
@@ -343,6 +396,12 @@ export function initCombobox(root: HTMLElement): () => void {
|
|
|
343
396
|
: null;
|
|
344
397
|
|
|
345
398
|
const resetFilter = (): void => {
|
|
399
|
+
if (mode === 'number') {
|
|
400
|
+
const bounds = getNumberBounds(root);
|
|
401
|
+
if (bounds) syncNumberOptionBounds(root, bounds);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
346
405
|
if (search) search.value = '';
|
|
347
406
|
for (const option of getOptions(root)) option.hidden = false;
|
|
348
407
|
if (empty) empty.hidden = getOptions(root).length > 0;
|
|
@@ -736,6 +795,11 @@ export function initCombobox(root: HTMLElement): () => void {
|
|
|
736
795
|
close();
|
|
737
796
|
};
|
|
738
797
|
|
|
798
|
+
const handleNumberBoundsChange = (): void => {
|
|
799
|
+
setActiveOption(null);
|
|
800
|
+
schedulePanelPosition();
|
|
801
|
+
};
|
|
802
|
+
|
|
739
803
|
if (mode === 'options' && trigger && search) {
|
|
740
804
|
control.addEventListener('click', handleOptionsControlClick);
|
|
741
805
|
trigger.addEventListener('click', handleOptionsTriggerClick);
|
|
@@ -759,6 +823,10 @@ export function initCombobox(root: HTMLElement): () => void {
|
|
|
759
823
|
}
|
|
760
824
|
optionsRoot.addEventListener('click', handleOptionClick);
|
|
761
825
|
root.addEventListener('combobox:clear', handleClear);
|
|
826
|
+
root.addEventListener(
|
|
827
|
+
'combobox:number-bounds-change',
|
|
828
|
+
handleNumberBoundsChange,
|
|
829
|
+
);
|
|
762
830
|
|
|
763
831
|
return () => {
|
|
764
832
|
close();
|
|
@@ -778,6 +846,10 @@ export function initCombobox(root: HTMLElement): () => void {
|
|
|
778
846
|
}
|
|
779
847
|
optionsRoot.removeEventListener('click', handleOptionClick);
|
|
780
848
|
root.removeEventListener('combobox:clear', handleClear);
|
|
849
|
+
root.removeEventListener(
|
|
850
|
+
'combobox:number-bounds-change',
|
|
851
|
+
handleNumberBoundsChange,
|
|
852
|
+
);
|
|
781
853
|
panel.style.removeProperty('--combobox-panel-inline-start');
|
|
782
854
|
panel.style.removeProperty('--combobox-panel-block-start');
|
|
783
855
|
panel.style.removeProperty('--combobox-panel-inline-size');
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
min-inline-size: 0;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
.table[data-table-column-density='compact']
|
|
20
|
+
.table[data-table-column-density='compact'],
|
|
21
|
+
.table[data-table-layout='index'] {
|
|
21
22
|
--table-cell-padding-inline: var(--nox-space-1);
|
|
22
23
|
--table-edge-padding-inline: var(--nox-space-3);
|
|
23
24
|
--table-header-letter-spacing: 0;
|
|
@@ -102,12 +102,14 @@ const stateMessage =
|
|
|
102
102
|
</tr>
|
|
103
103
|
) : (
|
|
104
104
|
rows.map((row) => {
|
|
105
|
-
const rowHref = row.href;
|
|
105
|
+
const rowHref = row.disabled ? undefined : row.href;
|
|
106
106
|
|
|
107
107
|
return (
|
|
108
108
|
<tr
|
|
109
109
|
class="table-body__row"
|
|
110
110
|
hidden={row.hidden}
|
|
111
|
+
aria-disabled={row.disabled ? 'true' : undefined}
|
|
112
|
+
data-disabled={row.disabled ? 'true' : undefined}
|
|
111
113
|
data-table-row
|
|
112
114
|
data-table-row-key={row.key}
|
|
113
115
|
data-table-row-href={rowHref}
|
|
@@ -166,6 +166,12 @@
|
|
|
166
166
|
cursor: pointer;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
.table-body__row[data-disabled='true'] {
|
|
170
|
+
opacity: 0.4;
|
|
171
|
+
filter: grayscale(0.65);
|
|
172
|
+
cursor: not-allowed;
|
|
173
|
+
}
|
|
174
|
+
|
|
169
175
|
.table-body__row[data-table-row-href]:focus-within {
|
|
170
176
|
--table-primary-link-color: var(--nox-link-hover);
|
|
171
177
|
}
|