@juwel-development/design-system 3.6.0 → 3.8.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.
@@ -0,0 +1,140 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import { type FunctionComponent, type ReactNode, useId } from 'react';
3
+ import type { Subject } from 'rxjs';
4
+
5
+ // Field styling follows Input and ADRs 0001–0004; native appearance retains the platform picker.
6
+ const selectRoot = cva(
7
+ [
8
+ 'flex flex-col gap-[var(--space-stack)]',
9
+ '[&>label]:font-secondary [&>label]:font-medium [&>label]:text-body [&>label]:text-foreground',
10
+ '[&>span]:font-secondary [&>span]:text-muted [&>span]:text-small',
11
+ '[&>p]:font-secondary [&>p]:text-small [&>p]:text-muted [&>p[data-error]]:text-error',
12
+ '[&>select]:block [&>select]:w-full [&>select]:rounded-[var(--radius-control)] [&>select]:border [&>select]:border-solid [&>select]:border-control-border [&>select]:bg-transparent [&>select]:px-3 [&>select]:py-2',
13
+ '[&>select]:font-primary [&>select]:text-body [&>select]:text-foreground [&>select]:transition-colors [&>select]:duration-[var(--motion-duration-color)]',
14
+ '[&>select]:outline-focus-ring [&>select]:outline-offset-[var(--focus-ring-offset)] [&>select]:focus-visible:outline [&>select]:focus-visible:outline-[length:var(--focus-ring-width)]',
15
+ '[&>select:user-invalid]:border-error [&>select]:aria-[invalid=true]:border-error [&>select]:disabled:cursor-not-allowed [&>select]:disabled:border-disabled [&>select]:disabled:text-muted',
16
+ ].join(' '),
17
+ );
18
+
19
+ export interface ISelectRootProps {
20
+ /** Always rendered and associated with the control. */
21
+ label: string;
22
+ /** How the surrounding form reads the value on submit. */
23
+ name: string;
24
+ required?: boolean;
25
+ /** Caller-localized marker, rendered only when the field is optional. */
26
+ optionalLabel?: string;
27
+ disabled?: boolean;
28
+ invalid?: boolean;
29
+ hint?: string;
30
+ errorMessage?: string;
31
+ testId?: string;
32
+ /** Initial selection only; omitted or unmatched values start empty. */
33
+ defaultValue?: string;
34
+ /** Emits the selected value only on user changes; never on render or native form reset. */
35
+ onChange$?: Subject<string>;
36
+ /** Caller-localized wording for the first, empty option, which remains selectable. */
37
+ placeholder: string;
38
+ /** Compose Select.Option children; arrays, fragments and consumer components are supported. */
39
+ children?: ReactNode;
40
+ }
41
+
42
+ /**
43
+ * An uncontrolled native single-select field. The first option is always empty and selectable;
44
+ * `required` makes that empty value invalid. Option updates preserve a surviving selected value
45
+ * and fall back to empty when it disappears, without emitting. The form reads the value by name.
46
+ */
47
+ const SelectRoot: FunctionComponent<ISelectRootProps> = ({
48
+ label,
49
+ name,
50
+ required,
51
+ optionalLabel,
52
+ disabled,
53
+ invalid,
54
+ hint,
55
+ errorMessage,
56
+ testId,
57
+ defaultValue,
58
+ onChange$,
59
+ placeholder,
60
+ children,
61
+ }) => {
62
+ const id = useId();
63
+ const controlId = `${id}-control`;
64
+ const hintId = `${id}-hint`;
65
+ const errorId = `${id}-error`;
66
+ const describedBy =
67
+ [hint ? hintId : undefined, invalid && errorMessage ? errorId : undefined]
68
+ .filter(Boolean)
69
+ .join(' ') || undefined;
70
+
71
+ return (
72
+ <div className={selectRoot()}>
73
+ <label htmlFor={controlId}>{label}</label>
74
+ <select
75
+ id={controlId}
76
+ name={name}
77
+ required={required}
78
+ disabled={disabled}
79
+ defaultValue={defaultValue ?? ''}
80
+ aria-invalid={invalid || undefined}
81
+ aria-describedby={describedBy}
82
+ data-testid={testId}
83
+ onChange={(event) =>
84
+ !disabled && onChange$?.next(event.currentTarget.value)
85
+ }
86
+ >
87
+ <option value={''}>{placeholder}</option>
88
+ {children}
89
+ </select>
90
+ {!required && optionalLabel && <span>{optionalLabel}</span>}
91
+ {hint && <p id={hintId}>{hint}</p>}
92
+ {invalid && errorMessage && (
93
+ <p id={errorId} data-error>
94
+ {errorMessage}
95
+ </p>
96
+ )}
97
+ </div>
98
+ );
99
+ };
100
+
101
+ export interface ISelectOptionProps {
102
+ /** Unique, stable, nonempty identity; the empty string belongs to Root's placeholder. */
103
+ value: string;
104
+ /** Caller-localized text; native options do not accept rich content. */
105
+ children: string;
106
+ testId?: string;
107
+ }
108
+
109
+ const SelectOption: FunctionComponent<ISelectOptionProps> = ({
110
+ value,
111
+ children,
112
+ testId,
113
+ }) => (
114
+ <option value={value} data-testid={testId}>
115
+ {children}
116
+ </option>
117
+ );
118
+
119
+ /**
120
+ * An uncontrolled native single-select field composed from Root and Option.
121
+ * Root owns the label, selectable empty placeholder and field messages; Option renders text.
122
+ *
123
+ * @Guarantees
124
+ * - Required rejects empty; forms read the current stable value by name.
125
+ * - onChange$ emits user changes (including clearing), never rendering, option updates or reset.
126
+ * - Omitted/unmatched defaults start empty; later defaultValue changes do not select a value.
127
+ * - Removing the selected option falls back to empty without emitting.
128
+ *
129
+ * @CallerMustEnsure
130
+ * - Compose Option members under Root; arrays, fragments and consumer components are supported.
131
+ * - Option values are unique, stable and nonempty, with stable React keys when mapping children.
132
+ * - The consumer owns all wording, stream lifetime and domain reconciliation on updates/reset.
133
+ * - Native reset restores the original default while its option stays mounted, otherwise empty.
134
+ * A newly mounted option does not inherit a removed option's reset default; remount Root to
135
+ * initialize another record. Translation/reordering with stable keys preserves native state.
136
+ */
137
+ export const Select = {
138
+ Root: SelectRoot,
139
+ Option: SelectOption,
140
+ } as const;
@@ -244,6 +244,15 @@ const SLIDER = `:root {
244
244
  --slider-thumb-size: 1.5rem;
245
245
  }`;
246
246
 
247
+ /* The choice marker's two dimensions are not colours: like the tick they live in :root only,
248
+ never @theme inline, so a brand re-points the marker without Choices gaining a prop. Sized in
249
+ rem like the tick and the slider thumb. Constraints: both > 0 (a zero dot erases the one cue
250
+ that survives without colour perception), and dot < size or the dot escapes its box. */
251
+ const CHOICE_MARKER = `:root {
252
+ --choice-marker-size: 1.125rem;
253
+ --choice-marker-dot-size: 0.5rem;
254
+ }`;
255
+
247
256
  /* Not a colour: like the tick it lives in :root only, never @theme inline, so a brand can
248
257
  re-point the bar's weight. Meter reads it as h-[var(--meter-track-thickness)]. Constraint:
249
258
  > 0 - a zero-thickness track erases the display. 0.5rem is the height the accepted Negotiation
@@ -356,6 +365,9 @@ ${TAB_INSETS}
356
365
 
357
366
  ${SLIDER}
358
367
 
368
+ /* The choice marker's dimensions are not colours either, and sit in :root beside the slider block. */
369
+ ${CHOICE_MARKER}
370
+
359
371
  /* The meter track's thickness is not a colour either, and sits in :root beside the tab blocks. */
360
372
  ${METER_TRACK}
361
373
 
package/src/index.ts CHANGED
@@ -21,8 +21,10 @@ export { Note } from 'Display/Typography/Note/Note';
21
21
  export { P } from 'Display/Typography/P/P';
22
22
  export { Prose } from 'Display/Typography/Prose/Prose';
23
23
  export { Button } from 'Interaction/Button/Button';
24
+ export { Choices } from 'Interaction/Choices/Choices';
24
25
  export { Input } from 'Interaction/Input/Input';
25
26
  export { Link } from 'Interaction/Link/Link';
27
+ export { Select } from 'Interaction/Select/Select';
26
28
  export { Slider } from 'Interaction/Slider/Slider';
27
29
  export { Tabs } from 'Interaction/Tabs/Tabs';
28
30
  export { TextArea } from 'Interaction/TextArea/TextArea';
package/src/tokens.css CHANGED
@@ -252,6 +252,12 @@
252
252
  --slider-thumb-size: 1.5rem;
253
253
  }
254
254
 
255
+ /* The choice marker's dimensions are not colours either, and sit in :root beside the slider block. */
256
+ :root {
257
+ --choice-marker-size: 1.125rem;
258
+ --choice-marker-dot-size: 0.5rem;
259
+ }
260
+
255
261
  /* The meter track's thickness is not a colour either, and sits in :root beside the tab blocks. */
256
262
  :root {
257
263
  --meter-track-thickness: 0.5rem;
@@ -220,6 +220,12 @@
220
220
  --slider-thumb-size: 1.5rem;
221
221
  }
222
222
 
223
+ /* The choice marker's dimensions are not colours either, and sit in :root beside the slider block. */
224
+ :root {
225
+ --choice-marker-size: 1.125rem;
226
+ --choice-marker-dot-size: 0.5rem;
227
+ }
228
+
223
229
  /* The meter track's thickness is not a colour either, and sits in :root beside the tab blocks. */
224
230
  :root {
225
231
  --meter-track-thickness: 0.5rem;
@@ -220,6 +220,12 @@
220
220
  --slider-thumb-size: 1.5rem;
221
221
  }
222
222
 
223
+ /* The choice marker's dimensions are not colours either, and sit in :root beside the slider block. */
224
+ :root {
225
+ --choice-marker-size: 1.125rem;
226
+ --choice-marker-dot-size: 0.5rem;
227
+ }
228
+
223
229
  /* The meter track's thickness is not a colour either, and sits in :root beside the tab blocks. */
224
230
  :root {
225
231
  --meter-track-thickness: 0.5rem;