@juwel-development/design-system 3.11.0 → 4.0.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.
Files changed (66) hide show
  1. package/README.md +16 -3
  2. package/dist/design-system.js +1029 -540
  3. package/dist/styles.css +2 -0
  4. package/dist/styles.dark.css +2 -0
  5. package/dist/styles.light.css +2 -0
  6. package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +38 -18
  7. package/dist/types/Display/Callout/Callout.d.ts +31 -0
  8. package/dist/types/Display/Icon/Icon.d.ts +20 -24
  9. package/dist/types/Display/Meter/Meter.d.ts +5 -6
  10. package/dist/types/Display/Rule/Rule.d.ts +22 -0
  11. package/dist/types/Display/Table/Table.d.ts +57 -7
  12. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
  13. package/dist/types/Display/Typography/H1/H1.d.ts +1 -1
  14. package/dist/types/Display/Typography/H2/H2.d.ts +1 -1
  15. package/dist/types/Display/Typography/H3/H3.d.ts +1 -1
  16. package/dist/types/Display/Typography/H4/H4.d.ts +1 -1
  17. package/dist/types/Display/Typography/H5/H5.d.ts +1 -1
  18. package/dist/types/Display/Typography/H6/H6.d.ts +1 -1
  19. package/dist/types/Display/Typography/Note/Note.d.ts +16 -3
  20. package/dist/types/Display/Typography/P/P.d.ts +15 -2
  21. package/dist/types/Display/Typography/Prose/Prose.d.ts +14 -6
  22. package/dist/types/Interaction/Button/Button.d.ts +10 -4
  23. package/dist/types/Interaction/Slider/Slider.d.ts +3 -0
  24. package/dist/types/Interaction/Stepper/Stepper.d.ts +59 -0
  25. package/dist/types/Interaction/Stepper/StepperCompositionError.d.ts +3 -0
  26. package/dist/types/Interaction/Switch/Switch.d.ts +20 -0
  27. package/dist/types/Interaction/Tabs/Tabs.d.ts +24 -3
  28. package/dist/types/Layout/Dialog/Dialog.d.ts +11 -3
  29. package/dist/types/Layout/Form/FormConfigurationError.d.ts +3 -0
  30. package/dist/types/Layout/Pane/Pane.d.ts +55 -0
  31. package/dist/types/Layout/Shell/Shell.d.ts +39 -0
  32. package/dist/types/Layout/Sidebar/Sidebar.d.ts +16 -2
  33. package/dist/types/Theme/Palette.d.ts +11 -4
  34. package/dist/types/index.d.ts +21 -1
  35. package/package.json +18 -8
  36. package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +86 -28
  37. package/src/Display/Callout/Callout.tsx +64 -0
  38. package/src/Display/Icon/Icon.tsx +53 -13
  39. package/src/Display/Meter/Meter.tsx +12 -13
  40. package/src/Display/Rule/Rule.tsx +31 -0
  41. package/src/Display/Table/Table.tsx +189 -24
  42. package/src/Display/Typography/Note/Note.tsx +27 -11
  43. package/src/Display/Typography/P/P.tsx +29 -4
  44. package/src/Display/Typography/Prose/Prose.tsx +44 -19
  45. package/src/Interaction/Button/Button.tsx +11 -4
  46. package/src/Interaction/Slider/Slider.tsx +3 -5
  47. package/src/Interaction/Stepper/Stepper.tsx +307 -0
  48. package/src/Interaction/Stepper/StepperCompositionError.ts +6 -0
  49. package/src/Interaction/Switch/Switch.tsx +64 -0
  50. package/src/Interaction/Tabs/Tabs.tsx +44 -10
  51. package/src/Layout/Dialog/Dialog.tsx +19 -6
  52. package/src/Layout/Form/Form.tsx +2 -1
  53. package/src/Layout/Form/FormConfigurationError.ts +6 -0
  54. package/src/Layout/Pane/Pane.tsx +159 -0
  55. package/src/Layout/Shell/Shell.tsx +124 -0
  56. package/src/Layout/Sidebar/Sidebar.tsx +31 -7
  57. package/src/Theme/Palette.ts +17 -6
  58. package/src/Theme/renderTokens.ts +61 -12
  59. package/src/index.ts +21 -2
  60. package/src/styles.css +5 -6
  61. package/src/styles.dark.css +5 -6
  62. package/src/styles.light.css +5 -6
  63. package/src/tokens.css +54 -9
  64. package/src/tokens.dark.css +51 -8
  65. package/src/tokens.light.css +49 -6
  66. package/dist/index.css +0 -2
@@ -19,6 +19,10 @@ import { ColumnLayoutConfigurationError } from './ColumnLayoutConfigurationError
19
19
  // the role from one declaration. Nothing here measures: the arrangement needs no JavaScript.
20
20
  const columnLayout = cva('flex flex-wrap items-start', {
21
21
  variants: {
22
+ fill: {
23
+ false: '',
24
+ true: 'h-full min-h-0 content-start [--column-layout-shortfall:clamp(0px,(var(--column-layout-threshold)-100cqi)*1000000,1px)] [&>div]:[@container_style(--column-layout-shortfall:0px)]:h-full [&>div]:[@container_style(--column-layout-shortfall:0px)]:min-h-0',
25
+ },
22
26
  gap: {
23
27
  stack:
24
28
  'gap-[var(--space-stack)] [--column-layout-gap:var(--space-stack)]',
@@ -26,30 +30,38 @@ const columnLayout = cva('flex flex-wrap items-start', {
26
30
  'gap-[var(--space-region)] [--column-layout-gap:var(--space-region)]',
27
31
  },
28
32
  },
29
- defaultVariants: { gap: 'region' },
33
+ defaultVariants: { gap: 'region', fill: false },
30
34
  });
31
35
 
36
+ // A fill frame supplies the query width without imposing block-size containment. The row's
37
+ // registered shortfall resolves against it, so style queries release the columns when stacked.
38
+ const columnLayoutFrame = cva(
39
+ 'h-full min-h-0 min-w-0 [container-type:inline-size]',
40
+ );
41
+
32
42
  // The all-or-one switch, after Heydon Pickering's "Holy Albatross": `100%` is the Root's width W
33
43
  // and the threshold T sits on the Root, so W >= T clamps the basis to 0 (one line, grown by weight)
34
44
  // and W < T amplifies the shortfall past 100% (a line each). The factor turns a 1/64px shortfall,
35
45
  // Chrome's layout grain, into a full switch; `0px` and `100%` are the two states, not measurements.
36
46
  const columnLayoutColumn = cva(
37
- 'min-w-0 grow-[var(--column-layout-weight)] basis-[clamp(0px,(var(--column-layout-threshold)-100%)*1000000,100%)]',
47
+ 'min-w-0 grow-[calc(var(--column-layout-weight)/var(--column-layout-growth-unit))] basis-[clamp(0px,(var(--column-layout-threshold)-100%)*1000000,100%)] max-w-[var(--column-layout-maximum)]',
38
48
  );
39
49
 
40
- // React's CSSProperties is closed over known properties; the two custom properties the recipes
50
+ // React's CSSProperties is closed over known properties; the custom properties the recipes
41
51
  // read are declared here so the style objects stay typed without an assertion.
42
52
  type ColumnLayoutRootStyle = CSSProperties & {
43
53
  '--column-layout-threshold': string;
54
+ '--column-layout-growth-unit': number;
44
55
  };
45
56
  type ColumnLayoutColumnStyle = CSSProperties & {
46
57
  '--column-layout-weight': number;
58
+ '--column-layout-maximum': string;
47
59
  };
48
60
 
49
61
  /** The name of a CSS custom property, as written in a stylesheet: `--main-column-min-width`. */
50
- type MinWidthToken = `--${string}`;
62
+ type WidthToken = `--${string}`;
51
63
 
52
- type Track = { weight: number; minWidth: MinWidthToken };
64
+ type Track = { weight: number; minWidth: WidthToken; maxWidth?: WidthToken };
53
65
 
54
66
  // Granted only to a Column the Root counted: the Root wraps each direct Column child in it, and a
55
67
  // Column revokes it for its own descendants. Anything else is outside the composition.
@@ -63,7 +75,7 @@ const toTrack = (
63
75
  element: ReactElement<IColumnLayoutColumnProps>,
64
76
  index: number,
65
77
  ): Track => {
66
- const { weight, minWidth } = element.props;
78
+ const { weight, minWidth, maxWidth } = element.props;
67
79
  if (!Number.isFinite(weight) || weight <= 0) {
68
80
  throw new ColumnLayoutConfigurationError(
69
81
  `column ${index + 1} needs a positive finite weight (got ${weight})`,
@@ -74,7 +86,12 @@ const toTrack = (
74
86
  `column ${index + 1} needs a custom-property name such as --main-column-min-width for minWidth (got ${JSON.stringify(minWidth)})`,
75
87
  );
76
88
  }
77
- return { weight, minWidth };
89
+ if (maxWidth !== undefined && !TOKEN_NAME.test(maxWidth)) {
90
+ throw new ColumnLayoutConfigurationError(
91
+ `column ${index + 1} needs a custom-property name such as --support-column-max-width for maxWidth (got ${JSON.stringify(maxWidth)})`,
92
+ );
93
+ }
94
+ return { weight, minWidth, maxWidth };
78
95
  };
79
96
 
80
97
  // The documented threshold, `(n - 1) * g + max(m_i * S / w_i)`, left to the browser to resolve
@@ -87,6 +104,12 @@ const thresholdOf = (tracks: readonly Track[]): string => {
87
104
  return `calc(${tracks.length - 1} * var(--column-layout-gap) + max(${shares.join(', ')}))`;
88
105
  };
89
106
 
107
+ // Flex factors below one leave surplus unallocated, even after other columns freeze at caps.
108
+ const growthUnitOf = (tracks: readonly Track[]): number =>
109
+ tracks.some((track) => track.maxWidth !== undefined)
110
+ ? Math.min(1, ...tracks.map((track) => track.weight))
111
+ : 1;
112
+
90
113
  const isFragment = (
91
114
  node: ReactNode,
92
115
  ): node is ReactElement<{ children?: ReactNode }> =>
@@ -94,6 +117,9 @@ const isFragment = (
94
117
 
95
118
  export interface IColumnLayoutRootProps
96
119
  extends VariantProps<typeof columnLayout> {
120
+ /** Accept the parent's allocated height and pass it to horizontal columns. Unsized parents
121
+ * and stacked columns retain content height; the holder owns stacked overflow. */
122
+ fill?: VariantProps<typeof columnLayout>['fill'];
97
123
  /** The columns, as `ColumnLayout.Column` elements: direct children, or arrays and fragments of
98
124
  * them. A conditional that renders nothing reserves nothing. */
99
125
  children?: ReactNode;
@@ -107,7 +133,11 @@ export interface IColumnLayoutColumnProps {
107
133
  /** The name of the consumer's custom property holding this column's minimum readable width,
108
134
  * such as `--main-column-min-width`. A token name, never a length or a `var()`: the consumer
109
135
  * declares it in the theme with a nonnegative CSS length. */
110
- minWidth: MinWidthToken;
136
+ minWidth: WidthToken;
137
+ /** Optional horizontal cap, named by a theme custom property. Stacked columns ignore it.
138
+ * The theme must supply a nonnegative CSS length at least as large as minWidth; resolved
139
+ * lengths are not read or validated by the component. */
140
+ maxWidth?: WidthToken;
111
141
  /** The column's content. Rendered unmodified: the column imposes no anatomy. */
112
142
  children?: ReactNode;
113
143
  testId?: string;
@@ -147,6 +177,7 @@ const markColumns = (children: ReactNode): ReactNode =>
147
177
 
148
178
  const ColumnLayoutRoot: FunctionComponent<IColumnLayoutRootProps> = ({
149
179
  gap,
180
+ fill,
150
181
  children,
151
182
  testId,
152
183
  }) => {
@@ -154,16 +185,25 @@ const ColumnLayoutRoot: FunctionComponent<IColumnLayoutRootProps> = ({
154
185
  const style: ColumnLayoutRootStyle | undefined =
155
186
  tracks.length === 0
156
187
  ? undefined
157
- : { '--column-layout-threshold': thresholdOf(tracks) };
158
- return (
159
- <div className={columnLayout({ gap })} style={style} data-testid={testId}>
188
+ : {
189
+ '--column-layout-threshold': thresholdOf(tracks),
190
+ '--column-layout-growth-unit': growthUnitOf(tracks),
191
+ };
192
+ const row = (
193
+ <div
194
+ className={columnLayout({ gap, fill })}
195
+ style={style}
196
+ data-testid={testId}
197
+ >
160
198
  {markColumns(children)}
161
199
  </div>
162
200
  );
201
+ return fill ? <div className={columnLayoutFrame()}>{row}</div> : row;
163
202
  };
164
203
 
165
204
  const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
166
205
  weight,
206
+ maxWidth,
167
207
  children,
168
208
  testId,
169
209
  }) => {
@@ -171,7 +211,13 @@ const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
171
211
  if (!isCounted) {
172
212
  throw new ColumnLayoutCompositionError();
173
213
  }
174
- const style: ColumnLayoutColumnStyle = { '--column-layout-weight': weight };
214
+ const style: ColumnLayoutColumnStyle = {
215
+ '--column-layout-weight': weight,
216
+ '--column-layout-maximum':
217
+ maxWidth === undefined
218
+ ? 'none'
219
+ : `max(var(${maxWidth}), clamp(0px, (var(--column-layout-threshold) - 100%) * 1000000, 100%))`,
220
+ };
175
221
  return (
176
222
  <div className={columnLayoutColumn()} style={style} data-testid={testId}>
177
223
  <ColumnLayoutMembership.Provider value={false}>
@@ -189,45 +235,57 @@ const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
189
235
  * one answers to the width of its holder (docs/adr/0008, Amendments).
190
236
  *
191
237
  * @Guarantees — enforced on every render
192
- * - `Root` renders a `div` with no landmark role, no heading and no margin. Each `Column` is a
193
- * `div` directly inside it, in the order written, so DOM, reading and keyboard order are the
238
+ * - `Root` renders plain containers with no landmark role, no heading and no margin. Each
239
+ * `Column` is a `div` directly inside the row, so DOM, reading and keyboard order are the
194
240
  * order of the children in both arrangements, and no column is ever remounted merely because
195
241
  * the arrangement changed: resizing keeps every column's state and focus.
196
242
  * - In the horizontal arrangement the gaps are taken off the Root's content width and the rest is
197
243
  * divided in proportion to the weights: `2`, `1`, `1` is one half and two quarters of it. The
198
- * columns align at the top and keep their own heights.
244
+ * columns align at the top and keep their own heights by default (`fill={false}`). Optional
245
+ * maximums cap each allocation and redistribute surplus by weight among columns still able
246
+ * to grow. If all are capped, unused width stays at the trailing edge with unchanged gaps.
247
+ * - With `fill`, Root accepts its parent's allocated height and every horizontal column takes
248
+ * that height, bounding a filling Pane, Table or ScrollContainer. An internal inline-size
249
+ * frame supplies the width query; columns remain direct children of the row.
250
+ * - In an unsized parent, `fill` invents no bound: columns retain content height. When stacked,
251
+ * columns return to content height and the holder owns scrolling. No column scrolls itself.
199
252
  * - The row holds only while every proportional share is at least its own minimum. The moment one
200
253
  * falls short, every column takes a line of its own and fills the Root's width - a column
201
254
  * narrower than its minimum included: the minimum decides the switch and never floors a width.
202
- * There is no in-between: no column wraps alone, no share is clamped, nothing is redistributed to
203
- * postpone the switch. For n columns, gap g, total weight S and minimums m_i the row fits at and
204
- * above `(n - 1) * g + max(m_i * S / w_i)`.
255
+ * There is no in-between: no column wraps alone. Caps and redistribution never postpone
256
+ * the switch; stacked columns ignore their maximums. For n columns, gap g, total weight S and
257
+ * minimums m_i the row fits at and above `(n - 1) * g + max(m_i * S / w_i)`.
205
258
  * - The space measured is the Root's own width, never the viewport, so two instances on one page
206
259
  * switch independently, and a narrow holder on a wide screen stacks.
207
- * - Every minimum is read from the theme as a CSS length each time layout runs, so re-pointing a
208
- * token - in a theme class, a media query, or a scope around the Root - moves the threshold
209
- * with it, and a font-relative length moves it as the type does.
260
+ * - Every bound is read from the theme as a CSS length each time layout runs, so re-pointing a
261
+ * token - in a theme class, a media query, or a scope around the Root - updates allocation.
262
+ * Minimums move the threshold; maximums change caps. Font-relative bounds follow the type.
210
263
  * - `gap` selects which space role separates the columns, across the row and between the stacked
211
264
  * lines alike: `region` (the default), the gap between groups of blocks, or `stack`, the gap
212
265
  * between siblings within one block. Nothing else.
213
266
  * - Only rendered columns count: a conditional that renders nothing reserves neither width nor
214
- * gap, a single column fills the width, and an empty Root holds no tracks and no gaps.
267
+ * gap, a single uncapped column fills the width, and an empty Root holds no tracks and no gaps.
215
268
  * Allocations follow every change of columns, weights, gap, holder width or theme.
216
269
  * - A column adds no scrolling, truncation or overflow treatment of its own and does not widen its
217
270
  * track for its content: the content keeps whatever wrapping or scrolling contract it has.
218
271
  * - Every value it emits is a role the token layer or the consumer's theme already names; the
219
272
  * only literals are the switch's two states.
220
- * - A non-positive or non-finite `weight`, or a `minWidth` that is not a custom-property name,
273
+ * - A non-positive or non-finite `weight`, or a bound that is not a custom-property name,
221
274
  * throws {@link ColumnLayoutConfigurationError}; a `Column` that is not a direct child of a
222
275
  * `Root` - loose, nested in another column, or reached through a wrapping component - throws
223
276
  * {@link ColumnLayoutCompositionError}. Both are programmer errors, raised loud and early.
224
277
  *
225
278
  * @CallerMustEnsure — the component cannot see these and does not check them
226
- * - Every `minWidth` token is declared, on the Root or an ancestor of it, as a valid nonnegative
227
- * CSS length: `24rem`, `20em`, `40ch`, `320px`. A `rem` resolves against the document root, an
228
- * `em` or `ch` against the Root's inherited type. A missing or invalid token is not a
229
- * responsive configuration: the switch has nothing to compare and the columns size from their
230
- * content instead.
279
+ * - Every `minWidth` and supplied `maxWidth` token is declared, on the Root or an ancestor of it,
280
+ * as a valid nonnegative CSS length: `24rem`, `20em`, `40ch`, `320px`. A `rem` resolves against
281
+ * the document root, an `em` or `ch` against the Root's inherited type. Missing or invalid
282
+ * bounds are outside this contract and can invalidate the switch or cap.
283
+ * - Maximums must be at least their column's minimum in every scope and responsive state.
284
+ * Percentages are outside the contract. Resolved lengths are not read by the component;
285
+ * maximum below minimum has no guaranteed runtime exception. A valid maximum below the
286
+ * original share at the threshold applies immediately, with its surplus redistributed.
287
+ * - With `fill`, the holder supplies a definite height to bound content and owns overflow for
288
+ * the stacked fallback. Filling children own their overflow; fixed content must fit the bound.
231
289
  * - The holder gives the Root a definite width, as any block does. Inside a shrink-to-fit frame
232
290
  * there is no width to measure the shares against.
233
291
  * - Content that cannot wrap - an unbroken string, a fixed-width control - needs its own overflow
@@ -0,0 +1,64 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode } from 'react';
3
+
4
+ const callout = cva(
5
+ 'min-w-0 border-s-[length:var(--callout-mark-width)] border-solid ps-[var(--space-callout-inset)] text-foreground [overflow-wrap:anywhere] forced-colors:border-s-[CanvasText]',
6
+ {
7
+ variants: {
8
+ tone: {
9
+ warning: 'border-warning',
10
+ info: 'border-info',
11
+ success: 'border-success',
12
+ error: 'border-error',
13
+ },
14
+ },
15
+ defaultVariants: { tone: 'warning' },
16
+ },
17
+ );
18
+
19
+ export interface ICalloutProps extends VariantProps<typeof callout> {
20
+ /** Content and its arrangement belong to the consumer. */
21
+ children?: ReactNode;
22
+ /** Optional accessible group name, never rendered as visible text. Empty means unnamed. */
23
+ name?: string;
24
+ testId?: string;
25
+ }
26
+
27
+ /**
28
+ * A status-toned leading-edge mark beside consumer-owned content, without a surrounding box.
29
+ * @Guarantees
30
+ * - Supports warning (default), info, success and error through the theme's status tones.
31
+ * - The logical leading border follows writing direction; forced colours use CanvasText.
32
+ * - Only the leading edge is inset; no fill, outer space, layout, clipping or fixed size is added.
33
+ * - Named content is a group; unnamed content has no role. Neither adds focus or announcements.
34
+ * - Children keep their order, arrangement and native keyboard behaviour.
35
+ * @CallerMustEnsure
36
+ * - State the status and its consequence in visible text or a named Icon; colour is supplementary.
37
+ * - Keep the optional name consistent with any visible heading. Own any live announcements.
38
+ * - Keep tones at least 3:1 against the surrounding surface; the shared 4.5:1 text floor clears it.
39
+ * - Theme --callout-mark-width is a positive CSS length; --space-callout-inset is nonnegative.
40
+ * - Content with intrinsic sizing owns its overflow; Callout wraps prose but never clips it.
41
+ * @UXGuidelines
42
+ * - Mark a block needing attention; the consumer owns its text, controls and status meaning.
43
+ */
44
+ export const Callout: FunctionComponent<ICalloutProps> = ({
45
+ children,
46
+ tone,
47
+ name,
48
+ testId,
49
+ }) =>
50
+ name === undefined || name === '' ? (
51
+ <div className={callout({ tone })} data-testid={testId}>
52
+ {children}
53
+ </div>
54
+ ) : (
55
+ // biome-ignore lint/a11y/useSemanticElements: a content group must not acquire fieldset form semantics or intrinsic minimum sizing
56
+ <div
57
+ className={callout({ tone })}
58
+ role="group"
59
+ aria-label={name}
60
+ data-testid={testId}
61
+ >
62
+ {children}
63
+ </div>
64
+ );
@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
2
2
  import { cva } from 'class-variance-authority';
3
3
  import type { FunctionComponent } from 'react';
4
4
 
5
- // The glyph is sized in `em` and stroked in `currentColor`, so it takes the size and colour of the
5
+ // The glyph is sized in `em` and drawn in `currentColor`, so it takes the size and colour of the
6
6
  // text it sits in and needs no size prop. Baseline at -0.125em is what seats a 1em square beside
7
7
  // lowercase letters; the inline-box exists so an icon reads as a word in its line. `color` has no
8
8
  // default on purpose (docs/adr/0011, the #126 amendment): absent, no class competes with the text.
@@ -27,9 +27,13 @@ const icon = cva(
27
27
  const bubble =
28
28
  'M3.5 2h9a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7.25L4 13.75V11h-.5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z';
29
29
 
30
- // Each name selects a drawing and nothing else: drawings are told apart by their strokes, not by
30
+ // Each name selects a drawing and nothing else: drawings are told apart by their shapes, not by
31
31
  // colour or size, so they survive forced-colors mode and a monochrome print.
32
32
  const drawings = {
33
+ plus: ['M3 8h10', 'M8 3v10'],
34
+ right: ['M3 8h10', 'M9 4l4 4-4 4'],
35
+ remove: ['M3 8h10'],
36
+ dot: ['M8 5a3 3 0 1 0 0 6a3 3 0 1 0 0-6z'],
33
37
  sort: [
34
38
  'M5.5 13V3',
35
39
  'M3 5.5l2.5-2.5L8 5.5',
@@ -49,9 +53,7 @@ const drawings = {
49
53
  'bubble-tick': [bubble, 'M5.75 6.75l1.5 1.5 3-3.5'],
50
54
  } as const;
51
55
 
52
- export interface IIconProps extends VariantProps<typeof icon> {
53
- /** Which drawing. A name selects a shape, never a state or a behaviour. */
54
- name: keyof typeof drawings;
56
+ export interface IIconPartProps extends VariantProps<typeof icon> {
55
57
  /**
56
58
  * The accessible name that makes the icon a status mark (CONTEXT.md). Give it only where the icon
57
59
  * stands alone for a state; absent or empty, the icon stays hidden from assistive technology.
@@ -60,10 +62,19 @@ export interface IIconProps extends VariantProps<typeof icon> {
60
62
  testId?: string;
61
63
  }
62
64
 
65
+ export interface IIconProps extends IIconPartProps {
66
+ /** Legacy selector, retained alongside the compound drawing components. */
67
+ name: Exclude<keyof typeof drawings, 'plus' | 'right' | 'remove'>;
68
+ }
69
+
70
+ interface IIconDrawingProps extends IIconPartProps {
71
+ name: keyof typeof drawings;
72
+ }
73
+
63
74
  /**
64
- * A reusable visual glyph in six drawings: three sort indicators, a bin, and a speech bubble holding
65
- * an exclamation mark or a tick. Decorative beside the control or text that names it, or - given a
66
- * `label` - a status mark that stands alone for an item's state.
75
+ * Seven drawings: a filled dot, three sort indicators, a bin, and a bubble holding an exclamation
76
+ * mark or a tick. Decorative beside the control or text that names it, or - given a `label` - a
77
+ * status mark that stands alone for an item's state.
67
78
  *
68
79
  * @Guarantees — enforced on every render
69
80
  * - Out of the tab order in both forms: it adds no stop.
@@ -72,15 +83,18 @@ export interface IIconProps extends VariantProps<typeof icon> {
72
83
  * without a name is never rendered.
73
84
  * - Scales with the surrounding font-size and takes the surrounding text colour unless `color`
74
85
  * selects `muted` or a status tone. The tone changes colour only: no role, name or announcement.
75
- * - Every drawing differs in shape, so no meaning is carried by colour alone.
86
+ * - Every drawing differs in shape; the dot is filled, the other drawings are outlined.
87
+ * - Inline glyphs align with the first line of the text beside them.
76
88
  *
77
89
  * @CallerMustEnsure
78
90
  * - An unlabelled icon sits beside what carries the meaning: a `Button` label or `ariaLabel`, a
79
91
  * header cell's `ariaSort`. Inside a named control it stays unlabelled, or the name is said twice.
80
92
  * - A status mark's `label` states the status in the reader's language; the library words nothing.
81
- * - The two forms of one state use two drawings, never one drawing in two colours.
93
+ * - The two forms of one state use two drawings, never one drawing in two colours. When absence
94
+ * is the other form of a dot, the dot must not be the only carrier: give the state a name or
95
+ * a second cue, such as stronger text weight. A standalone dot still requires a label.
82
96
  */
83
- export const Icon: FunctionComponent<IIconProps> = ({
97
+ const IconDrawing: FunctionComponent<IIconDrawingProps> = ({
84
98
  name,
85
99
  label,
86
100
  color,
@@ -102,8 +116,8 @@ export const Icon: FunctionComponent<IIconProps> = ({
102
116
  <path
103
117
  key={segment}
104
118
  d={segment}
105
- fill={'none'}
106
- stroke={'currentColor'}
119
+ fill={name === 'dot' ? 'currentColor' : 'none'}
120
+ stroke={name === 'dot' ? 'none' : 'currentColor'}
107
121
  strokeWidth={1.5}
108
122
  strokeLinecap={'round'}
109
123
  strokeLinejoin={'round'}
@@ -111,3 +125,29 @@ export const Icon: FunctionComponent<IIconProps> = ({
111
125
  ))}
112
126
  </svg>
113
127
  );
128
+
129
+ // All parts share the same SVG renderer and recipe; only their drawing is fixed.
130
+ const partOf =
131
+ (name: keyof typeof drawings): FunctionComponent<IIconPartProps> =>
132
+ ({ label, color, testId }) => (
133
+ <IconDrawing name={name} label={label} color={color} testId={testId} />
134
+ );
135
+
136
+ const LegacyIcon: FunctionComponent<IIconProps> = ({
137
+ name,
138
+ label,
139
+ color,
140
+ testId,
141
+ }) => <IconDrawing name={name} label={label} color={color} testId={testId} />;
142
+
143
+ /** Legacy named drawings and five compound action drawings, all sharing em sizing, currentColor,
144
+ * optional status tones, label and testId. Parts take no name. Leave parts decorative inside
145
+ * Buttons and supply the action-specific ariaLabel on the Button itself. Remove is a minus, not bin.
146
+ */
147
+ export const Icon = Object.assign(LegacyIcon, {
148
+ Plus: partOf('plus'),
149
+ Up: partOf('sort-ascending'),
150
+ Down: partOf('sort-descending'),
151
+ Right: partOf('right'),
152
+ Remove: partOf('remove'),
153
+ });
@@ -31,10 +31,15 @@ const meterFill = cva('h-full w-[calc(var(--meter-level)*100%)]', {
31
31
  // is declared here so the style object stays typed without an assertion.
32
32
  type MeterLevelStyle = CSSProperties & { '--meter-level': number };
33
33
 
34
+ const filledShare = (value: number, min: number, max: number): number => {
35
+ if (value <= min) return 0;
36
+ if (value >= max) return 1;
37
+ return (value - min) / (max - min);
38
+ };
39
+
34
40
  export interface IMeterProps extends VariantProps<typeof meterFill> {
35
- /** The current level. Must lie inside `[min, max]`; an outside value throws
36
- * {@link MeterConfigurationError} rather than clamping - a wrong number must not render as a
37
- * plausible level. */
41
+ /** The current level. Off-range values clamp the visual fill to the nearest bound;
42
+ * `aria-valuenow` retains the raw value. */
38
43
  value: number;
39
44
  /** The bottom of the scale. Defaults to `0`. */
40
45
  min?: number;
@@ -58,8 +63,8 @@ export interface IMeterProps extends VariantProps<typeof meterFill> {
58
63
  * - The root carries `role="meter"`, `aria-valuenow`/`aria-valuemin`/`aria-valuemax`, the `label`
59
64
  * as its accessible name, and `aria-valuetext` exactly when `valueText` is supplied. It is not
60
65
  * focusable, and the visual fill is hidden from the accessibility tree.
61
- * - The filled share is `(value - min) / (max - min)` of the container and begins at logical
62
- * inline-start under both LTR and RTL direction.
66
+ * - The filled share is `(value - min) / (max - min)`, clamped to `[0, 1]`, and begins at logical
67
+ * inline-start under both LTR and RTL direction. `aria-valuenow` retains the raw value.
63
68
  * - Neutral paints the fill with `meterFill`; `depleting` maps the normalized level linearly
64
69
  * through OKLab from `meterFill` at the maximum to `error` at the minimum - no threshold, no
65
70
  * wording, no icon (docs/adr/0010-meter-depletion-is-a-treatment.md).
@@ -68,7 +73,7 @@ export interface IMeterProps extends VariantProps<typeof meterFill> {
68
73
  * radius token - Meter is not a control), and draws a persistent one-pixel `rule` line that
69
74
  * identifies the whole capacity against the surface.
70
75
  * - Invalid numeric configuration throws {@link MeterConfigurationError}: non-finite `value`,
71
- * `min` or `max`, `min >= max`, or a value outside the inclusive range. Nothing is clamped.
76
+ * `min` or `max`, or `min >= max`.
72
77
  * - No visible wording of its own, no numeric readout, no caption slot, no `className`/`style`
73
78
  * passthrough.
74
79
  *
@@ -107,14 +112,8 @@ export const Meter: FunctionComponent<IMeterProps> = ({
107
112
  `min must be less than max (got min=${min}, max=${max})`,
108
113
  );
109
114
  }
110
- if (value < min || value > max) {
111
- throw new MeterConfigurationError(
112
- `value must lie within [min, max] (got value=${value}, min=${min}, max=${max})`,
113
- );
114
- }
115
-
116
115
  const style: MeterLevelStyle = {
117
- '--meter-level': (value - min) / (max - min),
116
+ '--meter-level': filledShare(value, min, max),
118
117
  };
119
118
 
120
119
  return (
@@ -0,0 +1,31 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent } from 'react';
3
+
4
+ const rule = cva(
5
+ 'w-full shrink-0 border-t border-solid border-border forced-colors:border-[CanvasText]',
6
+ );
7
+
8
+ export interface IRuleProps {
9
+ testId?: string;
10
+ }
11
+
12
+ /**
13
+ * A decorative horizontal hairline between two blocks of one arrangement.
14
+ *
15
+ * @Guarantees
16
+ * - One CSS pixel in the theme's border role, spanning its holder's width.
17
+ * - Forced colours use CanvasText so the hairline survives the user's palette.
18
+ * - Always hidden from assistive technology, with no role, content or focus stop.
19
+ * - No margin or padding: the surrounding arrangement owns space on both sides.
20
+ *
21
+ * @CallerMustEnsure
22
+ * - Place it between blocks in a column Stack; Stack's gap supplies the space role.
23
+ * - Rule is never a semantic separator. A thematic break that must be conveyed to
24
+ * assistive technology requires semantic markup instead of this component.
25
+ *
26
+ * @UXGuidelines
27
+ * - Use Collection for a semantic list with rules between items; Rule creates no list.
28
+ */
29
+ export const Rule: FunctionComponent<IRuleProps> = ({ testId }) => (
30
+ <div aria-hidden={'true'} className={rule()} data-testid={testId} />
31
+ );