@astronox/core 0.2.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # AstroNox Core
2
2
 
3
- AstroNox Core is the shared Astro foundation used by AstroNox sites. It owns
4
- reusable components, composition primitives, interaction patterns, display
5
- contracts, SEO helpers, and the complete default stylesheet.
3
+ AstroNox Core is a reusable foundation for Astro applications. It provides
4
+ components, composition primitives, interaction patterns, display contracts,
5
+ internationalization and SEO infrastructure, and a complete shared stylesheet.
6
6
 
7
7
  Product data, product routes, branding, integrations, and business rules stay
8
8
  in the consuming site.
@@ -33,11 +33,13 @@ import { Stack, SurfaceGroup } from '@astronox/core/composition';
33
33
  </Stack>
34
34
  ```
35
35
 
36
- Load the shared visual system once from the consuming application's stylesheet:
36
+ Load the shared visual system once from the consuming application's root
37
+ layout:
37
38
 
38
- ```css
39
- @import '@astronox/core/styles.css';
40
- @import './site-theme.css';
39
+ ```astro
40
+ ---
41
+ import '@astronox/core/styles.css';
42
+ ---
41
43
  ```
42
44
 
43
45
  Use the remaining entry points for layouts, patterns, primitives, display
@@ -50,26 +52,3 @@ and responsive media presentation are available through the `i18n`, `routing`,
50
52
  definitions, translations, route names, product metadata, and assets. Generic
51
53
  component labels can extend `defaultCoreMessages` through
52
54
  `resolveCoreMessages()` while retaining the English fallback.
53
-
54
- ## Local Development
55
-
56
- `astronox-core` is the only source of truth. A consuming project may link this
57
- package locally while developing a shared change, but committed dependencies
58
- must always reference a released package version.
59
-
60
- The expected workflow is:
61
-
62
- 1. Keep an exact published version in the consumer manifest and lockfile.
63
- 2. Link the sibling package locally without touching either file with
64
- `npm install --no-save --package-lock=false ../astronox-core`.
65
- 3. Implement and validate the generic change through the linked consumer.
66
- 4. Publish a prerelease or stable package version.
67
- 5. Update the exact consumer dependency before deployment.
68
-
69
- Do not edit installed package files or copy `src` folders between projects.
70
-
71
- ## Releases
72
-
73
- AstroNox Core follows semantic versioning. Public entry points are compatibility
74
- boundaries; internal files may change without notice. The initial `0.x` series
75
- is reserved for stabilizing the API with the first production consumers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -4,29 +4,37 @@ import type { DisplayMedia } from '../../../display/contracts';
4
4
  import type { Tone } from '../../../theme/contracts';
5
5
  import Media from '../../../primitives/media/Media/Media.astro';
6
6
 
7
- type Props = Omit<HTMLAttributes<'button'>, 'type'> & {
7
+ type SharedProps = {
8
8
  accentTone?: Tone;
9
9
  label: string;
10
10
  media: DisplayMedia;
11
11
  tone?: Tone;
12
12
  };
13
13
 
14
+ type Props = SharedProps &
15
+ Omit<HTMLAttributes<'a'>, 'href' | 'type'> & {
16
+ href?: string;
17
+ };
18
+
14
19
  const {
15
20
  accentTone,
16
21
  class: className,
17
22
  'data-tone': dataTone,
23
+ href,
18
24
  label,
19
25
  media,
20
26
  tone,
21
27
  ...attrs
22
28
  }: Props = Astro.props;
23
29
  const resolvedTone = tone ?? dataTone;
30
+ const Root = href ? 'a' : 'button';
24
31
  ---
25
32
 
26
- <button
33
+ <Root
27
34
  {...attrs}
28
35
  class:list={['media-trigger', className]}
29
- type="button"
36
+ href={href}
37
+ type={href ? undefined : 'button'}
30
38
  aria-label={label}
31
39
  data-tone={resolvedTone}
32
40
  data-widget="media-trigger"
@@ -52,4 +60,4 @@ const resolvedTone = tone ?? dataTone;
52
60
  </span>
53
61
  ) : null
54
62
  }
55
- </button>
63
+ </Root>
@@ -12,6 +12,7 @@
12
12
  background: transparent;
13
13
  color: inherit;
14
14
  font: inherit;
15
+ text-decoration: none;
15
16
  cursor: pointer;
16
17
  isolation: isolate;
17
18
  }
@@ -211,7 +211,6 @@
211
211
  .markdown-content__change-label {
212
212
  min-width: 0;
213
213
  flex: none;
214
- font-weight: var(--nox-font-weight-medium);
215
214
  overflow-wrap: anywhere;
216
215
  }
217
216
 
@@ -112,6 +112,7 @@ const listItems =
112
112
  <div class="markdown-content__change">
113
113
  <span class="markdown-content__change-label">
114
114
  {node.label.map((child) => <MarkdownInline node={child} />)}
115
+ <span>:</span>
115
116
  </span>
116
117
  {node.presentation === 'statement' ? (
117
118
  <span class="markdown-content__change-values">
@@ -0,0 +1,60 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import type { Tone } from '../../../theme/contracts';
4
+ import CheckIcon from '../../icons/CheckIcon.astro';
5
+ import CloseIcon from '../../icons/CloseIcon.astro';
6
+
7
+ type Props = Omit<HTMLAttributes<'input'>, 'class' | 'id' | 'type'> & {
8
+ id: string;
9
+ label: string;
10
+ class?: string;
11
+ invalid?: boolean;
12
+ tone?: Tone;
13
+ };
14
+
15
+ const {
16
+ 'aria-invalid': ariaInvalid,
17
+ class: className,
18
+ 'data-tone': dataTone,
19
+ disabled = false,
20
+ id,
21
+ invalid = false,
22
+ label,
23
+ tone,
24
+ ...attrs
25
+ }: Props = Astro.props;
26
+ const resolvedInvalid =
27
+ invalid || ariaInvalid === true || ariaInvalid === 'true';
28
+ const resolvedTone = tone ?? dataTone;
29
+ ---
30
+
31
+ <label
32
+ class:list={['checkbox-field', className]}
33
+ for={id}
34
+ data-disabled={disabled ? true : undefined}
35
+ data-invalid={resolvedInvalid ? true : undefined}
36
+ data-tone={resolvedTone}
37
+ data-widget="checkbox"
38
+ >
39
+ <input
40
+ {...attrs}
41
+ id={id}
42
+ class="checkbox"
43
+ type="checkbox"
44
+ disabled={disabled}
45
+ aria-invalid={resolvedInvalid ? 'true' : ariaInvalid}
46
+ />
47
+ <span class="checkbox__label">
48
+ <span class="checkbox__label-text">{label}</span>
49
+ <span class="checkbox__state" aria-hidden="true">
50
+ <CheckIcon
51
+ class="checkbox__icon checkbox__icon--checked"
52
+ data-tone="success"
53
+ />
54
+ <CloseIcon
55
+ class="checkbox__icon checkbox__icon--unchecked"
56
+ data-tone="danger"
57
+ />
58
+ </span>
59
+ </span>
60
+ </label>
@@ -0,0 +1,126 @@
1
+ .checkbox-field {
2
+ position: relative;
3
+ display: grid;
4
+ box-sizing: border-box;
5
+ min-inline-size: 0;
6
+ inline-size: 100%;
7
+ block-size: var(--nox-field-control-block-size);
8
+ place-items: center;
9
+ padding: var(--nox-space-2);
10
+ overflow: clip;
11
+ border: var(--nox-border-width) solid var(--nox-field-border);
12
+ border-radius: var(--nox-radius-sm);
13
+ background: var(--nox-field-background-disabled);
14
+ color: var(--nox-text-disabled);
15
+ cursor: pointer;
16
+ transition:
17
+ border-color var(--nox-motion-duration-sm) var(--nox-motion-easing-standard),
18
+ box-shadow var(--nox-motion-duration-sm) var(--nox-motion-easing-standard),
19
+ background-color var(--nox-motion-duration-sm)
20
+ var(--nox-motion-easing-standard),
21
+ color var(--nox-motion-duration-sm) var(--nox-motion-easing-standard);
22
+ }
23
+
24
+ .checkbox {
25
+ position: absolute;
26
+ z-index: 1;
27
+ inset: 0;
28
+ inline-size: 100%;
29
+ block-size: 100%;
30
+ margin: 0;
31
+ opacity: 0;
32
+ cursor: pointer;
33
+ }
34
+
35
+ .checkbox__label {
36
+ display: inline-grid;
37
+ min-inline-size: 0;
38
+ max-inline-size: 100%;
39
+ grid-template-columns: 0.75rem minmax(0, auto) 0.75rem;
40
+ align-items: center;
41
+ gap: var(--nox-space-1);
42
+ color: inherit;
43
+ font-size: var(--nox-font-size-xs);
44
+ font-weight: var(--nox-font-weight-strong);
45
+ letter-spacing: var(--nox-letter-spacing-wide);
46
+ line-height: 1;
47
+ overflow-wrap: anywhere;
48
+ text-align: center;
49
+ text-transform: uppercase;
50
+ }
51
+
52
+ .checkbox__label::before {
53
+ content: '';
54
+ inline-size: 0.75rem;
55
+ }
56
+
57
+ .checkbox__label-text {
58
+ min-inline-size: 0;
59
+ overflow: hidden;
60
+ text-overflow: ellipsis;
61
+ white-space: nowrap;
62
+ }
63
+
64
+ .checkbox__state {
65
+ display: grid;
66
+ inline-size: 0.75rem;
67
+ block-size: 0.75rem;
68
+ place-items: center;
69
+ }
70
+
71
+ .checkbox__icon {
72
+ grid-area: 1 / 1;
73
+ inline-size: 100%;
74
+ block-size: 100%;
75
+ transition: opacity var(--nox-motion-duration-sm)
76
+ var(--nox-motion-easing-standard);
77
+ }
78
+
79
+ .checkbox__icon--checked {
80
+ color: var(--nox-tone-color);
81
+ opacity: 0;
82
+ }
83
+
84
+ .checkbox__icon--unchecked {
85
+ color: var(--nox-text-disabled);
86
+ opacity: 1;
87
+ }
88
+
89
+ .checkbox-field:has(.checkbox:checked) {
90
+ background: var(--nox-field-background);
91
+ color: var(--nox-tone-text);
92
+ }
93
+
94
+ .checkbox:checked ~ .checkbox__label .checkbox__icon--checked {
95
+ opacity: 1;
96
+ }
97
+
98
+ .checkbox:checked ~ .checkbox__label .checkbox__icon--unchecked {
99
+ opacity: 0;
100
+ }
101
+
102
+ .checkbox-field:has(.checkbox:focus-visible) {
103
+ border-color: var(--nox-field-border-active);
104
+ box-shadow: var(--nox-field-focus-shadow);
105
+ }
106
+
107
+ .checkbox-field[data-invalid] {
108
+ border-color: var(--nox-field-border-invalid);
109
+ }
110
+
111
+ .checkbox-field[data-disabled] {
112
+ border-color: var(--nox-field-border);
113
+ background: var(--nox-field-background-disabled);
114
+ color: var(--nox-text-disabled);
115
+ cursor: not-allowed;
116
+ }
117
+
118
+ .checkbox-field[data-disabled] .checkbox {
119
+ cursor: not-allowed;
120
+ }
121
+
122
+ @media (hover: hover) and (pointer: fine) {
123
+ .checkbox-field:not([data-disabled]):hover {
124
+ border-color: var(--nox-field-border-hover);
125
+ }
126
+ }
@@ -0,0 +1,34 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import type { Tone } from '../../../theme/contracts';
4
+
5
+ type Props = Omit<HTMLAttributes<'fieldset'>, 'disabled'> & {
6
+ label: string;
7
+ disabled?: boolean;
8
+ tone?: Tone;
9
+ };
10
+
11
+ const {
12
+ class: className,
13
+ 'data-tone': dataTone,
14
+ disabled = false,
15
+ label,
16
+ tone,
17
+ ...attrs
18
+ }: Props = Astro.props;
19
+ const resolvedTone = tone ?? dataTone;
20
+ ---
21
+
22
+ <fieldset
23
+ {...attrs}
24
+ class:list={['field-group', className]}
25
+ disabled={disabled}
26
+ data-disabled={disabled ? true : undefined}
27
+ data-tone={resolvedTone}
28
+ data-widget="field-group"
29
+ >
30
+ <legend class="field-group__label">{label}</legend>
31
+ <div class="field-group__content">
32
+ <slot />
33
+ </div>
34
+ </fieldset>
@@ -0,0 +1,30 @@
1
+ .field-group {
2
+ min-inline-size: 0;
3
+ margin: 0;
4
+ padding: 0;
5
+ border: 0;
6
+ }
7
+
8
+ .field-group__label {
9
+ display: inline-flex;
10
+ min-inline-size: 0;
11
+ margin: 0;
12
+ padding: 0;
13
+ align-items: center;
14
+ color: var(--nox-tone-text);
15
+ font-size: var(--nox-font-size-sm);
16
+ font-weight: var(--nox-font-weight-strong);
17
+ letter-spacing: var(--nox-letter-spacing-wide);
18
+ line-height: 1.25;
19
+ overflow-wrap: anywhere;
20
+ text-transform: uppercase;
21
+ }
22
+
23
+ .field-group__content {
24
+ min-inline-size: 0;
25
+ margin-block-start: var(--nox-space-2);
26
+ }
27
+
28
+ .field-group[data-disabled] .field-group__label {
29
+ color: var(--nox-text-disabled);
30
+ }
@@ -0,0 +1,22 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+
4
+ type Props = HTMLAttributes<'svg'>;
5
+
6
+ const attrs: Props = Astro.props;
7
+ ---
8
+
9
+ <svg
10
+ {...attrs}
11
+ viewBox="0 0 32 32"
12
+ fill="none"
13
+ aria-hidden="true"
14
+ focusable="false"
15
+ >
16
+ <path
17
+ d="M7 16.5L13 22.5L25 9.5"
18
+ stroke="currentColor"
19
+ stroke-width="2.5"
20
+ stroke-linecap="round"
21
+ stroke-linejoin="round"></path>
22
+ </svg>
@@ -15,7 +15,9 @@ export { default as IconList } from './data-display/IconList/IconList.astro';
15
15
  export { default as MetricTile } from './data-display/MetricTile/MetricTile.astro';
16
16
  export { default as Tag } from './data-display/Tag/Tag.astro';
17
17
  export { default as TextPair } from './data-display/TextPair/TextPair.astro';
18
+ export { default as Checkbox } from './forms/Checkbox/Checkbox.astro';
18
19
  export { default as FieldError } from './forms/FieldError/FieldError.astro';
20
+ export { default as FieldGroup } from './forms/FieldGroup/FieldGroup.astro';
19
21
  export { default as FieldLabel } from './forms/FieldLabel/FieldLabel.astro';
20
22
  export { default as TextInput } from './forms/TextInput/TextInput.astro';
21
23
  export { default as Spinner } from './feedback/Spinner/Spinner.astro';
@@ -3,6 +3,7 @@ import type { HTMLAttributes } from 'astro/types';
3
3
 
4
4
  type GridGap = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
5
5
  type GridColumns = 1 | 2 | 3 | 4 | 'content-sidebar';
6
+ type GridFlow = 'row' | 'column';
6
7
  type GridRows = 1 | 2 | 3 | 4;
7
8
  type GridItemAlignment = 'start' | 'center' | 'end' | 'stretch';
8
9
  type GridCollapseAt = 'none' | 'sm' | 'md' | 'lg' | 'xl';
@@ -14,8 +15,10 @@ type Props = Omit<HTMLAttributes<'div'>, 'class' | 'style'> & {
14
15
  collapseAt?: GridCollapseAt;
15
16
  class?: string;
16
17
  compactColumns?: 1 | 2 | 3 | 4;
18
+ compactFlow?: GridFlow;
17
19
  gap?: GridGap;
18
20
  columns?: GridColumns;
21
+ flow?: GridFlow;
19
22
  justifyItems?: GridItemAlignment;
20
23
  lastRow?: GridLastRow;
21
24
  minTrackWidth?: string;
@@ -28,8 +31,10 @@ const {
28
31
  collapseAt = 'md',
29
32
  class: className,
30
33
  compactColumns,
34
+ compactFlow,
31
35
  gap = 'md',
32
36
  columns,
37
+ flow = 'row',
33
38
  justifyItems = 'stretch',
34
39
  lastRow = 'preserve',
35
40
  minTrackWidth = '16rem',
@@ -52,6 +57,8 @@ const gridVariables = {
52
57
  ...(compactColumns
53
58
  ? { '--grid-compact-columns': String(compactColumns) }
54
59
  : {}),
60
+ '--grid-compact-flow': compactFlow ?? flow,
61
+ '--grid-flow': flow,
55
62
  ...(rows ? { '--grid-rows': String(rows) } : {}),
56
63
  '--grid-align-items': alignItems,
57
64
  '--grid-justify-items': justifyItems,
@@ -69,7 +76,9 @@ const gridStyle = Object.entries(gridVariables)
69
76
  data-align-items={alignItems}
70
77
  data-collapse-at={collapseMode}
71
78
  data-compact-columns={compactColumns}
79
+ data-compact-flow={compactFlow}
72
80
  data-columns={typeof columns === 'number' ? columns : undefined}
81
+ data-flow={flow}
73
82
  data-gap={gap}
74
83
  data-justify-items={justifyItems}
75
84
  data-layout={layout}
@@ -8,6 +8,7 @@
8
8
  min-width: 0;
9
9
  align-items: var(--grid-align-items);
10
10
  gap: var(--grid-gap);
11
+ grid-auto-flow: var(--grid-flow);
11
12
  justify-items: var(--grid-justify-items);
12
13
  }
13
14
 
@@ -300,7 +301,58 @@
300
301
 
301
302
  @container (width < 24rem) {
302
303
  .grid[data-compact-columns] {
304
+ grid-auto-flow: var(--grid-compact-flow);
303
305
  grid-template-columns: repeat(var(--grid-compact-columns), minmax(0, 1fr));
306
+ grid-template-rows: none;
307
+ }
308
+
309
+ .grid[data-compact-columns]
310
+ > [data-composition='grid-item'][data-compact-span='1'] {
311
+ grid-column: span 1;
312
+ }
313
+
314
+ .grid[data-compact-columns]
315
+ > [data-composition='grid-item'][data-compact-span='2'] {
316
+ grid-column: span 2;
317
+ }
318
+
319
+ .grid[data-compact-columns]
320
+ > [data-composition='grid-item'][data-compact-span='3'] {
321
+ grid-column: span 3;
322
+ }
323
+
324
+ .grid[data-compact-columns]
325
+ > [data-composition='grid-item'][data-compact-span='4'] {
326
+ grid-column: span 4;
327
+ }
328
+
329
+ .grid[data-compact-columns]
330
+ > [data-composition='grid-item'][data-compact-row-span='1'] {
331
+ grid-row: span 1;
332
+ }
333
+
334
+ .grid[data-compact-columns]
335
+ > [data-composition='grid-item'][data-compact-row-span='2'] {
336
+ grid-row: span 2;
337
+ }
338
+
339
+ .grid[data-compact-columns]
340
+ > [data-composition='grid-item'][data-compact-row-span='3'] {
341
+ grid-row: span 3;
342
+ }
343
+
344
+ .grid[data-compact-columns]
345
+ > [data-composition='grid-item'][data-compact-row-span='4'] {
346
+ grid-row: span 4;
347
+ }
348
+
349
+ .grid[data-compact-columns]
350
+ > [data-composition='grid-item'][data-sizing='fill-track'] {
351
+ --grid-item-fill-track-contain: none;
352
+ --grid-item-fill-track-content-block-size: auto;
353
+ --grid-item-fill-track-content-inset: auto;
354
+ --grid-item-fill-track-content-position: static;
355
+ --grid-item-fill-track-overflow: visible;
304
356
  }
305
357
 
306
358
  .grid[data-last-row='fill'][data-compact-columns] > * {
@@ -14,8 +14,11 @@ type Props = Omit<GridItemAttributes, 'class' | 'style'> & {
14
14
  alignSelf?: GridItemAlignment;
15
15
  class?: string;
16
16
  collapseOrder?: GridItemCollapseOrder;
17
+ compactRowSpan?: GridItemSpan;
18
+ compactSpan?: GridItemSpan;
17
19
  justifySelf?: GridItemAlignment;
18
20
  position?: GridItemPosition;
21
+ rowSpan?: GridItemSpan;
19
22
  sizing?: GridItemSizing;
20
23
  span?: GridItemSpan;
21
24
  };
@@ -25,8 +28,11 @@ const {
25
28
  alignSelf = 'auto',
26
29
  class: className,
27
30
  collapseOrder = 'preserve',
31
+ compactRowSpan,
32
+ compactSpan,
28
33
  justifySelf = 'auto',
29
34
  position = 'static',
35
+ rowSpan = 1,
30
36
  sizing = 'auto',
31
37
  span = 1,
32
38
  ...attrs
@@ -39,8 +45,11 @@ const {
39
45
  data-composition="grid-item"
40
46
  data-align-self={alignSelf}
41
47
  data-collapse-order={collapseOrder}
48
+ data-compact-row-span={compactRowSpan}
49
+ data-compact-span={compactSpan}
42
50
  data-justify-self={justifySelf}
43
51
  data-position={position}
52
+ data-row-span={rowSpan}
44
53
  data-sizing={sizing}
45
54
  data-span={span}
46
55
  >
@@ -69,6 +69,18 @@
69
69
  grid-column: span 4;
70
70
  }
71
71
 
72
+ .grid-item[data-row-span='2'] {
73
+ grid-row: span 2;
74
+ }
75
+
76
+ .grid-item[data-row-span='3'] {
77
+ grid-row: span 3;
78
+ }
79
+
80
+ .grid-item[data-row-span='4'] {
81
+ grid-row: span 4;
82
+ }
83
+
72
84
  .grid-item[data-sizing='fill-track'] {
73
85
  --grid-item-fill-track-contain: size layout;
74
86
  --grid-item-fill-track-content-block-size: 100%;
@@ -242,6 +242,7 @@ export type TableCell = {
242
242
 
243
243
  export type TableRow = {
244
244
  key?: string;
245
+ disabled?: boolean;
245
246
  href?: string;
246
247
  hidden?: boolean;
247
248
  filters?: Readonly<Record<string, readonly string[]>>;
@@ -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
  }
@@ -13,8 +13,57 @@ type CachedFilterValues = {
13
13
  };
14
14
 
15
15
  const initializedAttribute = 'data-table-filters-initialized';
16
+ const externalFiltersAttribute = 'data-table-filter-external';
17
+ const exclusionsAttribute = 'data-table-filter-exclusions';
18
+ const refreshEvent = 'table-filter:refresh';
16
19
  const noop = (): void => undefined;
17
20
 
21
+ export function setTableExternalFilterActive(
22
+ root: HTMLElement,
23
+ key: string,
24
+ active: boolean,
25
+ ): void {
26
+ setTokenAttribute(root, externalFiltersAttribute, key, active);
27
+ }
28
+
29
+ export function setTableRowFilterExclusion(
30
+ row: HTMLElement,
31
+ key: string,
32
+ excluded: boolean,
33
+ ): void {
34
+ setTokenAttribute(row, exclusionsAttribute, key, excluded);
35
+ }
36
+
37
+ function setTokenAttribute(
38
+ element: HTMLElement,
39
+ attribute: string,
40
+ key: string,
41
+ enabled: boolean,
42
+ ): void {
43
+ const normalizedKey = key.trim();
44
+ if (!normalizedKey || /\s/.test(normalizedKey)) {
45
+ throw new Error('Table filter keys must be non-empty tokens');
46
+ }
47
+
48
+ const tokens = new Set(
49
+ (element.getAttribute(attribute) ?? '')
50
+ .split(/\s+/)
51
+ .filter(Boolean),
52
+ );
53
+ if (enabled) tokens.add(normalizedKey);
54
+ else tokens.delete(normalizedKey);
55
+
56
+ if (tokens.size > 0) {
57
+ element.setAttribute(attribute, [...tokens].join(' '));
58
+ } else {
59
+ element.removeAttribute(attribute);
60
+ }
61
+ }
62
+
63
+ export function refreshTableFilters(root: HTMLElement): void {
64
+ root.dispatchEvent(new Event(refreshEvent));
65
+ }
66
+
18
67
  export function initTableFilters(root: HTMLElement): () => void {
19
68
  if (root.getAttribute(initializedAttribute) === 'true') return noop;
20
69
 
@@ -79,7 +128,11 @@ export function initTableFilters(root: HTMLElement): () => void {
79
128
  const activeSelections = [...selections.entries()].filter(
80
129
  ([, value]) => value.length > 0,
81
130
  );
82
- const active = query.length > 0 || activeSelections.length > 0;
131
+ const hasExternalFilter =
132
+ root.hasAttribute(externalFiltersAttribute) ||
133
+ rows.some((row) => row.hasAttribute(exclusionsAttribute));
134
+ const active =
135
+ query.length > 0 || activeSelections.length > 0 || hasExternalFilter;
83
136
  let visibleRows = 0;
84
137
 
85
138
  for (const row of rows) {
@@ -92,7 +145,10 @@ export function initTableFilters(root: HTMLElement): () => void {
92
145
  ([field, selectedValue]) =>
93
146
  (cached.values[field] ?? []).includes(selectedValue),
94
147
  );
95
- const visible = matchesSearch && matchesSelections;
148
+ const visible =
149
+ matchesSearch &&
150
+ matchesSelections &&
151
+ !row.hasAttribute(exclusionsAttribute);
96
152
 
97
153
  if (row.hidden !== !visible) row.hidden = !visible;
98
154
  if (visible) visibleRows += 1;
@@ -168,6 +224,7 @@ export function initTableFilters(root: HTMLElement): () => void {
168
224
  applyFilters();
169
225
  search?.addEventListener('input', handleSearch);
170
226
  root.addEventListener('combobox:change', handleComboboxChange);
227
+ root.addEventListener(refreshEvent, scheduleFilters);
171
228
  table.addEventListener('table:rows-appended', handleRowsAppended);
172
229
  table.addEventListener(
173
230
  'table:progressive-complete',
@@ -182,6 +239,7 @@ export function initTableFilters(root: HTMLElement): () => void {
182
239
  }
183
240
  search?.removeEventListener('input', handleSearch);
184
241
  root.removeEventListener('combobox:change', handleComboboxChange);
242
+ root.removeEventListener(refreshEvent, scheduleFilters);
185
243
  table.removeEventListener('table:rows-appended', handleRowsAppended);
186
244
  table.removeEventListener(
187
245
  'table:progressive-complete',
@@ -25,7 +25,7 @@ const compactIndexRoleMinimums = {
25
25
 
26
26
  const regularIndexRoleMinimums = {
27
27
  ...compactIndexRoleMinimums,
28
- value: 7,
28
+ value: 8.5,
29
29
  } satisfies Record<TableColumnRole, number>;
30
30
 
31
31
  export function getTableColumnDensity(
@@ -39,6 +39,7 @@ export function getTableColumnTemplate(
39
39
  layout: TableLayout = 'default',
40
40
  indexSizing: TableIndexSizing = 'compact',
41
41
  ): string {
42
+ const columnDensity = getTableColumnDensity(columns);
42
43
  const indexMinimums = getIndexRoleMinimums(columns);
43
44
 
44
45
  return columns
@@ -46,7 +47,7 @@ export function getTableColumnTemplate(
46
47
  layout === 'index'
47
48
  ? indexSizing === 'overflow'
48
49
  ? 'max-content'
49
- : getIndexColumnTrack(column.role, indexMinimums)
50
+ : getIndexColumnTrack(column.role, indexMinimums, columnDensity)
50
51
  : getDefaultColumnTrack(column),
51
52
  )
52
53
  .join(' ');
@@ -91,9 +92,15 @@ function getIndexRoleMinimums(
91
92
  function getIndexColumnTrack(
92
93
  role: TableColumnRole,
93
94
  minimums: Record<TableColumnRole, number>,
95
+ density: 'compact' | 'regular',
94
96
  ): string {
95
97
  const minimum = `${minimums[role]}rem`;
96
- return role === 'primary' ? `minmax(${minimum}, 1fr)` : minimum;
98
+ if (role === 'primary') {
99
+ const weight = density === 'compact' ? 2 : 1;
100
+ return `minmax(${minimum}, ${weight}fr)`;
101
+ }
102
+ if (role === 'value') return `minmax(${minimum}, 1fr)`;
103
+ return minimum;
97
104
  }
98
105
 
99
106
  function getDefaultColumnTrack(column: TableColumn): string {
@@ -41,8 +41,10 @@
41
41
  @import '../components/data-display/Tag/Tag.css' layer(components);
42
42
  @import '../components/data-display/TextPair/TextPair.css'
43
43
  layer(components);
44
+ @import '../components/forms/Checkbox/Checkbox.css' layer(components);
44
45
  @import '../components/forms/FieldLabel/FieldLabel.css' layer(components);
45
46
  @import '../components/forms/FieldError/FieldError.css' layer(components);
47
+ @import '../components/forms/FieldGroup/FieldGroup.css' layer(components);
46
48
  @import '../components/forms/TextInput/TextInput.css' layer(components);
47
49
  @import '../components/feedback/Tooltip/Tooltip.css' layer(components);
48
50
  @import '../components/feedback/Spinner/Spinner.css' layer(components);