@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.
Files changed (40) hide show
  1. package/README.md +15 -27
  2. package/package.json +3 -1
  3. package/src/components/actions/MediaTrigger/MediaTrigger.astro +12 -4
  4. package/src/components/actions/MediaTrigger/MediaTrigger.css +1 -0
  5. package/src/components/content/MarkdownContent/MarkdownContent.css +0 -1
  6. package/src/components/content/MarkdownContent/MarkdownNode.astro +1 -0
  7. package/src/components/forms/Checkbox/Checkbox.astro +60 -0
  8. package/src/components/forms/Checkbox/Checkbox.css +126 -0
  9. package/src/components/forms/FieldGroup/FieldGroup.astro +34 -0
  10. package/src/components/forms/FieldGroup/FieldGroup.css +30 -0
  11. package/src/components/icons/CheckIcon.astro +22 -0
  12. package/src/components/index.ts +2 -0
  13. package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +2 -1
  14. package/src/components/navigation/SubNavigation/SubNavigation.astro +2 -1
  15. package/src/composition/Grid/Grid.astro +9 -0
  16. package/src/composition/Grid/Grid.css +52 -0
  17. package/src/composition/GridItem/GridItem.astro +9 -0
  18. package/src/composition/GridItem/GridItem.css +12 -0
  19. package/src/display/contracts.ts +1 -0
  20. package/src/display/index.ts +1 -0
  21. package/src/display/responsiveMedia.ts +38 -0
  22. package/src/i18n/LocalePreferenceBootstrap.astro +40 -0
  23. package/src/i18n/catalog.ts +203 -0
  24. package/src/i18n/config.ts +112 -0
  25. package/src/i18n/coreMessages.ts +37 -0
  26. package/src/i18n/formatters.ts +34 -0
  27. package/src/i18n/index.ts +6 -0
  28. package/src/i18n/preference.ts +58 -0
  29. package/src/index.ts +2 -0
  30. package/src/patterns/Combobox/comboboxController.ts +72 -0
  31. package/src/patterns/Table/Table.css +2 -1
  32. package/src/patterns/Table/TableBody.astro +3 -1
  33. package/src/patterns/Table/TableBody.css +6 -0
  34. package/src/patterns/Table/tableFilterController.ts +60 -2
  35. package/src/patterns/Table/tableLayout.ts +10 -3
  36. package/src/routing/index.ts +1 -0
  37. package/src/routing/localized.ts +155 -0
  38. package/src/seo/index.ts +1 -0
  39. package/src/seo/localizedMeta.ts +58 -0
  40. 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,6 @@
1
+ export { default as LocalePreferenceBootstrap } from './LocalePreferenceBootstrap.astro';
2
+ export * from './catalog';
3
+ export * from './config';
4
+ export * from './coreMessages';
5
+ export * from './formatters';
6
+ export * from './preference';
@@ -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
@@ -2,5 +2,7 @@ export * from './client/index';
2
2
  export * from './config/index';
3
3
  export * from './data/index';
4
4
  export * from './display/index';
5
+ export * from './i18n/index';
6
+ export * from './routing/index';
5
7
  export * from './seo/index';
6
8
  export * from './theme/index';
@@ -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
  }