@ethlete/components 1.0.0-next.76 → 1.0.0-next.78

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 (78) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/design/config.json +19 -0
  3. package/design/form-field.ts +40 -1
  4. package/design/icon-set.ts +19 -0
  5. package/design/icon.ts +9 -0
  6. package/design/sdk/components/button/button.ts +39 -0
  7. package/design/sdk/components/form-field/form-field.ts +100 -0
  8. package/design/sdk/components/select/select.css +5 -0
  9. package/design/sdk/components/select/select.ts +16 -0
  10. package/design/sdk/components/select/shared/select-part.ts +60 -0
  11. package/design/sdk/fixtures.ts +0 -0
  12. package/design/sdk/foundations/colors/colors.css +528 -0
  13. package/design/sdk/foundations/colors/colors.ts +188 -0
  14. package/design/sdk/foundations/colors/overrides.json +19 -0
  15. package/design/sdk/foundations/colors/shared/contrast.ts +120 -0
  16. package/design/select.ts +113 -28
  17. package/design/tree.ts +106 -0
  18. package/fesm2022/ethlete-components.mjs +15 -15
  19. package/fesm2022/ethlete-components.mjs.map +1 -1
  20. package/package.json +2 -2
  21. package/src/lib/forms/form-field/form-field-text-shell-styles.component.css +24 -16
  22. package/src/lib/forms/select/select-option-styles.component.css +3 -2
  23. package/src/lib/icon/headless/align-icons.ts +30 -0
  24. package/src/lib/icon/headless/arrow-out-up-right-icon.ts +17 -0
  25. package/src/lib/icon/headless/arrow-right-icon.ts +23 -0
  26. package/src/lib/icon/headless/arrow-up-icon.ts +30 -0
  27. package/src/lib/icon/headless/arrows-left-right-icon.ts +31 -0
  28. package/src/lib/icon/headless/bold-icon.ts +11 -0
  29. package/src/lib/icon/headless/calendar-icon.ts +21 -0
  30. package/src/lib/icon/headless/check-icon.ts +14 -0
  31. package/src/lib/icon/headless/chevron-icon.ts +14 -0
  32. package/src/lib/icon/headless/circle-check-icon.ts +14 -0
  33. package/src/lib/icon/headless/circle-info-icon.ts +14 -0
  34. package/src/lib/icon/headless/clipboard-check-icon.ts +28 -0
  35. package/src/lib/icon/headless/clock-icon.ts +21 -0
  36. package/src/lib/icon/headless/code-block-icon.ts +15 -0
  37. package/src/lib/icon/headless/code-icon.ts +14 -0
  38. package/src/lib/icon/headless/ellipsis-icon.ts +13 -0
  39. package/src/lib/icon/headless/ellipsis-vertical-icon.ts +13 -0
  40. package/src/lib/icon/headless/eye-icons.ts +27 -0
  41. package/src/lib/icon/headless/eyedropper-icon.ts +22 -0
  42. package/src/lib/icon/headless/file-icon.ts +16 -0
  43. package/src/lib/icon/headless/filter-icon.ts +11 -0
  44. package/src/lib/icon/headless/floppy-disk-icon.ts +27 -0
  45. package/src/lib/icon/headless/focus-frame-icon.ts +16 -0
  46. package/src/lib/icon/headless/grid-2x2-icon.ts +17 -0
  47. package/src/lib/icon/headless/heading-1-icon.ts +11 -0
  48. package/src/lib/icon/headless/heading-2-icon.ts +11 -0
  49. package/src/lib/icon/headless/heading-3-icon.ts +11 -0
  50. package/src/lib/icon/headless/icon-definition.ts +14 -0
  51. package/src/lib/icon/headless/image-icon.ts +14 -0
  52. package/src/lib/icon/headless/italic-icon.ts +19 -0
  53. package/src/lib/icon/headless/link-icon.ts +17 -0
  54. package/src/lib/icon/headless/list-bulleted-icon.ts +14 -0
  55. package/src/lib/icon/headless/list-numbered-icon.ts +14 -0
  56. package/src/lib/icon/headless/location-icon.ts +21 -0
  57. package/src/lib/icon/headless/lock-icon.ts +14 -0
  58. package/src/lib/icon/headless/minus-icon.ts +14 -0
  59. package/src/lib/icon/headless/paragraph-icon.ts +11 -0
  60. package/src/lib/icon/headless/pause-icon.ts +11 -0
  61. package/src/lib/icon/headless/pencil-icon.ts +21 -0
  62. package/src/lib/icon/headless/play-icon.ts +10 -0
  63. package/src/lib/icon/headless/plus-icon.ts +21 -0
  64. package/src/lib/icon/headless/quote-icon.ts +15 -0
  65. package/src/lib/icon/headless/rotate-right-icon.ts +16 -0
  66. package/src/lib/icon/headless/star-icon.ts +14 -0
  67. package/src/lib/icon/headless/strikethrough-icon.ts +18 -0
  68. package/src/lib/icon/headless/table-icon.ts +15 -0
  69. package/src/lib/icon/headless/thumbtack-icon.ts +18 -0
  70. package/src/lib/icon/headless/times-icon.ts +14 -0
  71. package/src/lib/icon/headless/trash-icon.ts +24 -0
  72. package/src/lib/icon/headless/triangle-exclamation-icon.ts +14 -0
  73. package/src/lib/icon/headless/trophy-icon.ts +24 -0
  74. package/src/lib/icon/headless/underline-icon.ts +12 -0
  75. package/src/lib/icon/headless/undo-redo-icons.ts +24 -0
  76. package/src/lib/icon/headless/upload-icon.ts +16 -0
  77. package/src/lib/tree/tree.component.css +2 -2
  78. package/types/ethlete-components.d.ts +52 -52
@@ -0,0 +1,188 @@
1
+ import { definePart, html, tokenSheet, type Drawing } from '@design-system';
2
+ import { contrastSheet, contrastStyles } from './shared/contrast';
3
+ import css from './colors.css?inline';
4
+
5
+ type Surface = 'light' | 'dark';
6
+
7
+ type Theme = 'brand' | 'danger' | 'warning';
8
+
9
+ type Props = { group: 'surface' | 'contrast' | Theme; surface: Surface };
10
+
11
+ const STATE_ENDS = ['', '-hover', '-focus', '-active', '-disabled'];
12
+
13
+ const labelOf = (end: string) => (end ? end.slice(1) : 'rest');
14
+
15
+ const SURFACE_TOKEN =
16
+ /^et-(surface-(background|color(-muted|-subtle)?|border|interaction(-hover|-focus|-active|-disabled)?)|shadow-color-rgb)$/u;
17
+
18
+ const COLOR_TOKEN = /^et-color-(on-)?primary(-ink)?(-hover|-focus|-active|-disabled)?$/u;
19
+
20
+ const TEXT_TOKEN = /^et-surface-(background|color(-muted|-subtle)?)$/u;
21
+
22
+ const themeClassOf = (theme: Theme, surface: Surface) => `et-color--${theme}${surface === 'light' ? '-on-light' : ''}`;
23
+
24
+ const demoNote = (theme: Theme, surface: Surface) =>
25
+ `${theme} is a demo theme of the SDK Storybook (apps/storybook/src/themes.ts); the SDK ships no theme names, each app registers its own.${surface === 'light' ? ` On a light surface the sheet reads .${themeClassOf(theme, surface)}.` : ''}`;
26
+
27
+ const swatch = (token: string) => html`<span class="sdk-swatch" style="background: rgb(var(--${token}))"></span>`;
28
+
29
+ const disabledOf = (name: string) => (name.endsWith('-disabled') ? 'aria-disabled="true"' : '');
30
+
31
+ const ink = (token: string, label = 'Aa') =>
32
+ html`<span class="sdk-ink" ${disabledOf(token)} style="color: rgb(var(--${token}))">${label}</span>`;
33
+
34
+ const surfaceSample = (name: string) => {
35
+ if (name === 'et-shadow-color-rgb') return html`<span class="sdk-swatch sdk-shadow"></span>`;
36
+ if (/^et-surface-color/u.test(name)) return ink(name);
37
+
38
+ return swatch(name);
39
+ };
40
+
41
+ const colorSample = (name: string) => {
42
+ const onFill = /^et-color-on-primary(.*)$/u.exec(name);
43
+ if (onFill)
44
+ return html`<span
45
+ class="sdk-fill"
46
+ ${disabledOf(name)}
47
+ style="background: rgb(var(--et-color-primary${onFill[1]})); color: rgb(var(--${name}))"
48
+ >Aa</span
49
+ >`;
50
+ if (name.startsWith('et-color-primary-ink')) return ink(name, 'Ink');
51
+
52
+ return swatch(name);
53
+ };
54
+
55
+ const surfacePanel = html`<div class="sdk-card">
56
+ <span>Text</span>
57
+ <span class="sdk-muted">Muted text</span>
58
+ <span class="sdk-subtle">Subtle text</span>
59
+ </div>
60
+ <div class="sdk-states">
61
+ ${STATE_ENDS.map(
62
+ (end) =>
63
+ html`<span class="sdk-state"
64
+ ><span class="sdk-swatch" style="background: rgb(var(--et-surface-interaction${end}))"></span
65
+ >${labelOf(end)}</span
66
+ >`,
67
+ ).join('')}
68
+ </div>
69
+ <div class="sdk-elevations">
70
+ <span class="sdk-elevation sdk-shadow-resting">Resting</span>
71
+ <span class="sdk-elevation sdk-shadow-raised">Raised</span>
72
+ </div>`;
73
+
74
+ const themePanel = html`<div class="sdk-states">
75
+ ${STATE_ENDS.map(
76
+ (end) =>
77
+ html`<span
78
+ class="sdk-fill sdk-fill--wide"
79
+ ${disabledOf(end)}
80
+ style="background: rgb(var(--et-color-primary${end})); color: rgb(var(--et-color-on-primary${end}))"
81
+ >${labelOf(end)}</span
82
+ >`,
83
+ ).join('')}
84
+ </div>
85
+ <div class="sdk-states">
86
+ ${STATE_ENDS.map(
87
+ (end) =>
88
+ html`<span class="sdk-link" ${disabledOf(end)} style="color: rgb(var(--et-color-primary-ink${end}))"
89
+ >Ink ${labelOf(end)}</span
90
+ >`,
91
+ ).join('')}
92
+ </div>`;
93
+
94
+ const scopeOf = ({ group, surface }: Props) =>
95
+ group === 'surface' || group === 'contrast'
96
+ ? `sdk-colors et-surface--${surface}`
97
+ : `sdk-colors et-surface--${surface} ${themeClassOf(group, surface)}`;
98
+
99
+ const titleOf = ({ group, surface }: Props) =>
100
+ group === 'surface'
101
+ ? `Surface, ${surface}`
102
+ : group === 'contrast'
103
+ ? `Contrast, ${surface}`
104
+ : `${group} (demo theme) on ${surface}`;
105
+
106
+ const sheetOf = (props: Props) => {
107
+ const { group, surface } = props;
108
+
109
+ if (group === 'surface')
110
+ return tokenSheet({
111
+ title: titleOf(props),
112
+ scope: scopeOf(props),
113
+ css,
114
+ sample: surfacePanel,
115
+ sampleOf: surfaceSample,
116
+ only: (name) => SURFACE_TOKEN.test(name),
117
+ });
118
+ if (group === 'contrast')
119
+ return tokenSheet({
120
+ title: titleOf(props),
121
+ scope: scopeOf(props),
122
+ css,
123
+ sample: contrastSheet(css, surface),
124
+ sampleClass: 'sdk-contrast',
125
+ sampleOf: surfaceSample,
126
+ only: (name) => TEXT_TOKEN.test(name),
127
+ });
128
+
129
+ return tokenSheet({
130
+ title: titleOf(props),
131
+ scope: scopeOf(props),
132
+ note: demoNote(group, surface),
133
+ css,
134
+ sample: themePanel,
135
+ sampleOf: colorSample,
136
+ only: (name) => COLOR_TOKEN.test(name),
137
+ });
138
+ };
139
+
140
+ const STYLES = `
141
+ ${contrastStyles}
142
+ .sdk-colors { --surface: var(--et-surface-background-solid); --text: var(--et-surface-color-solid); --text-muted: var(--et-surface-color-muted-solid); --surface-border: var(--et-surface-border-solid); --font-ui: var(--et-font-family); --font-code: var(--et-font-family-mono); width: max-content; }
143
+ .sdk-colors .ts-row { grid-template-columns: 300px 160px 1fr; }
144
+ section.ts-panel { display: flex; flex-direction: column; gap: 1.2rem; }
145
+ section.ts-panel.sdk-contrast { flex-direction: row; flex-wrap: wrap; gap: 2.4rem; }
146
+ .sdk-table { display: flex; flex-direction: column; gap: 0.8rem; }
147
+ .sdk-title { margin: 0; font-size: 1.4rem; font-weight: 600; }
148
+ .sdk-value { font-family: var(--et-font-family-mono); font-size: 1.2rem; color: var(--et-surface-color-muted-solid); white-space: nowrap; }
149
+ .sdk-card { display: flex; gap: 2.4rem; padding: 1.2rem 1.6rem; border: 0.1rem solid var(--et-surface-border-solid); border-radius: 0.6rem; }
150
+ .sdk-muted { color: var(--et-surface-color-muted-solid); }
151
+ .sdk-subtle { color: var(--et-surface-color-subtle-solid); }
152
+ .sdk-states { display: flex; flex-wrap: wrap; gap: 1.2rem; align-items: center; }
153
+ .sdk-state { display: inline-flex; align-items: center; gap: 0.6rem; font-size: 1.2rem; }
154
+ .sdk-swatch { display: block; width: 4rem; height: 2.4rem; border-radius: 0.4rem; border: 0.1rem solid var(--et-surface-border-solid); }
155
+ .sdk-shadow { background: var(--et-surface-background-solid); box-shadow: 0 0.6rem 1.2rem rgb(var(--et-shadow-color-rgb) / 0.22), 0 0.2rem 0.4rem rgb(var(--et-shadow-color-rgb) / 0.14); }
156
+ .sdk-ink { font-size: 1.6rem; font-weight: 600; }
157
+ .sdk-fill { display: inline-grid; place-items: center; min-width: 4rem; height: 2.4rem; padding: 0 0.8rem; box-sizing: border-box; border-radius: 0.4rem; font-size: 1.2rem; font-weight: 600; }
158
+ .sdk-fill--wide { min-width: 8rem; height: 3.2rem; }
159
+ .sdk-link { min-width: 8rem; font-size: 1.3rem; font-weight: 600; text-decoration: underline; }
160
+ .sdk-elevations { display: flex; gap: 2.4rem; padding: 0.8rem 0; }
161
+ .sdk-elevation { display: grid; place-items: center; width: 12rem; height: 4.8rem; border-radius: 0.6rem; background: var(--et-surface-background-solid); font-size: 1.2rem; }
162
+ .sdk-shadow-resting { box-shadow: 0 0.3rem 0.8rem rgb(var(--et-shadow-color-rgb) / 0.2), 0 0.1rem 0.3rem rgb(var(--et-shadow-color-rgb) / 0.12); }
163
+ .sdk-shadow-raised { box-shadow: 0 0.6rem 1.2rem rgb(var(--et-shadow-color-rgb) / 0.22), 0 0.2rem 0.4rem rgb(var(--et-shadow-color-rgb) / 0.14); }
164
+ `;
165
+
166
+ const render = (props: Props): Drawing => {
167
+ const sheet = sheetOf(props);
168
+
169
+ return { ...sheet, styles: `${sheet.styles ?? ''}${STYLES}` };
170
+ };
171
+
172
+ export default definePart<Props>({
173
+ note: 'The SDK colour tokens: a light and a dark surface, the demo colour themes brand, danger and warning with their on-light variants, and the AA contrast of text and fills. Every frame loads it before the part styles. Generated by tools/scripts/generate-sdk-tokens.mjs from the Storybook themes; never edit the CSS by hand.',
174
+ madeBy: ['claude-opus-5-5'],
175
+ states: {
176
+ 'surface-light': { group: 'surface', surface: 'light' },
177
+ 'surface-dark': { group: 'surface', surface: 'dark' },
178
+ 'brand-light': { group: 'brand', surface: 'light' },
179
+ 'brand-dark': { group: 'brand', surface: 'dark' },
180
+ 'danger-light': { group: 'danger', surface: 'light' },
181
+ 'danger-dark': { group: 'danger', surface: 'dark' },
182
+ 'warning-light': { group: 'warning', surface: 'light' },
183
+ 'warning-dark': { group: 'warning', surface: 'dark' },
184
+ 'contrast-light': { group: 'contrast', surface: 'light' },
185
+ 'contrast-dark': { group: 'contrast', surface: 'dark' },
186
+ },
187
+ render,
188
+ });
@@ -0,0 +1,19 @@
1
+ {
2
+ "extraColorThemes": ["brand-on-light", "danger-on-light", "warning-on-light"],
3
+ "values": {
4
+ "danger": {
5
+ "primary.color.hover": "185 28 28",
6
+ "primary.color.focus": "185 28 28",
7
+ "primary.color.active": "153 27 27"
8
+ },
9
+ "danger-on-light": {
10
+ "primary.color.hover": "185 28 28",
11
+ "primary.color.focus": "185 28 28",
12
+ "primary.color.active": "153 27 27"
13
+ },
14
+ "warning": { "primary.color.active": "251 191 36" },
15
+ "warning-on-light": { "primary.color.active": "251 191 36" },
16
+ "light": { "colorSubtle": "112 112 112" },
17
+ "dark": { "colorSubtle": "135 135 135" }
18
+ }
19
+ }
@@ -0,0 +1,120 @@
1
+ import { html } from '@design-system';
2
+
3
+ type Surface = 'light' | 'dark';
4
+
5
+ const tokenValues = (css: string, selector: string) => {
6
+ const start = css.indexOf(`${selector} {`);
7
+
8
+ if (start === -1) throw new Error(`colors.css has no ${selector} block`);
9
+
10
+ const block = css.slice(start, css.indexOf('}', start));
11
+
12
+ return Object.fromEntries(
13
+ [...block.matchAll(/(--[\w-]+): (\d+ \d+ \d+);/g)].map(([, name, value]) => [name, value as string]),
14
+ ) as Record<string, string>;
15
+ };
16
+
17
+ const channel = (value: number) => {
18
+ const c = value / 255;
19
+
20
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
21
+ };
22
+
23
+ const luminance = (rgb: string) => {
24
+ const [r = 0, g = 0, b = 0] = rgb.split(' ').map(Number);
25
+
26
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
27
+ };
28
+
29
+ const contrastRatio = (a: string, b: string) => {
30
+ const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
31
+
32
+ return (high + 0.05) / (low + 0.05);
33
+ };
34
+
35
+ const AA = 4.5;
36
+
37
+ const pair = (label: string, fill: string, ink: string, fixed: boolean) => {
38
+ const ratio = contrastRatio(fill, ink);
39
+
40
+ return html`<span class="sdk-pair" style="background: rgb(${fill}); color: rgb(${ink})">Aa</span>
41
+ <span class="sdk-pair-label${fixed ? ' sdk-fixed' : ''}">${label}</span>
42
+ <code class="sdk-value">${fill} / ${ink}</code>
43
+ <strong class="sdk-ratio" data-pass="${ratio >= AA}"
44
+ >${ratio.toFixed(2)}:1 ${ratio >= AA ? 'AA' : 'below AA'}</strong
45
+ >`;
46
+ };
47
+
48
+ const STATE_SUFFIX = { rest: '', hover: '-hover', focus: '-focus', active: '-active' } as const;
49
+
50
+ const FIXED: Record<string, (keyof typeof STATE_SUFFIX)[]> = { danger: ['hover', 'focus'], warning: ['active'] };
51
+
52
+ const pairTable = (title: string, rows: string) =>
53
+ html`<section class="sdk-table">
54
+ <h2 class="sdk-title">${title}</h2>
55
+ <div class="sdk-pairs">${rows}</div>
56
+ </section>`;
57
+
58
+ export const contrastSheet = (css: string, surface: Surface) => {
59
+ const level = tokenValues(css, `.et-surface--${surface}`);
60
+ const background = level['--et-surface-background'] ?? '';
61
+ const suffix = surface === 'light' ? '-on-light' : '';
62
+
63
+ const text = pairTable(
64
+ `Text on the ${surface} background`,
65
+ (
66
+ [
67
+ ['text', '--et-surface-color'],
68
+ ['muted', '--et-surface-color-muted'],
69
+ ['subtle', '--et-surface-color-subtle'],
70
+ ] as const
71
+ )
72
+ .map(([role, token]) => pair(role, background, level[token] ?? '', role === 'subtle'))
73
+ .join(''),
74
+ );
75
+
76
+ const fills = (['danger', 'warning'] as const)
77
+ .map((theme) => {
78
+ const values = tokenValues(css, `.et-color--${theme}${suffix}`);
79
+
80
+ return pairTable(
81
+ `.et-color--${theme}${suffix} (demo theme): on-primary on the fill`,
82
+ Object.entries(STATE_SUFFIX)
83
+ .map(([state, end]) =>
84
+ pair(
85
+ state,
86
+ values[`--et-color-primary${end}`] ?? '',
87
+ values[`--et-color-on-primary${end}`] ?? '',
88
+ FIXED[theme]?.includes(state as keyof typeof STATE_SUFFIX) ?? false,
89
+ ),
90
+ )
91
+ .join(''),
92
+ );
93
+ })
94
+ .join('');
95
+
96
+ const ink = pairTable(
97
+ `Ink on the ${surface} background`,
98
+ (['brand', 'danger', 'warning'] as const)
99
+ .map((theme) =>
100
+ pair(
101
+ `${theme}${suffix}`,
102
+ background,
103
+ tokenValues(css, `.et-color--${theme}${suffix}`)['--et-color-primary-ink'] ?? '',
104
+ false,
105
+ ),
106
+ )
107
+ .join(''),
108
+ );
109
+
110
+ return text + fills + ink;
111
+ };
112
+
113
+ export const contrastStyles = `
114
+ .sdk-pairs { display: grid; grid-template-columns: 4rem auto auto auto; gap: 0.6rem 1.2rem; align-items: center; margin-top: 0.4rem; }
115
+ .sdk-pair { display: grid; place-items: center; width: 4rem; height: 2.8rem; border-radius: 0.4rem; border: 0.1rem solid var(--et-surface-border-solid); font-size: 1.4rem; font-weight: 600; }
116
+ .sdk-pair-label { font-size: 1.2rem; white-space: nowrap; }
117
+ .sdk-fixed { font-weight: 600; }
118
+ .sdk-ratio { font-size: 1.2rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
119
+ .sdk-ratio[data-pass='false'] { color: var(--et-surface-color-muted-solid); text-decoration: line-through; }
120
+ `;
package/design/select.ts CHANGED
@@ -1,16 +1,46 @@
1
1
  import controlSuffix from '../src/lib/forms/form-field/form-field-control-suffix-styles.component.css?inline';
2
+ import selectAllOptionStyles from '../src/lib/forms/select/select-all-option.component.css?inline';
2
3
  import selectBase from '../src/lib/forms/select/select.component.css?inline';
3
- import selectOption from '../src/lib/forms/select/select-option-styles.component.css?inline';
4
- import selectPanel from '../src/lib/forms/select/select-panel.component.css?inline';
4
+ import selectOptionGroupStyles from '../src/lib/forms/select/select-option-group.component.css?inline';
5
+ import selectOptionStyles from '../src/lib/forms/select/select-option-styles.component.css?inline';
6
+ import selectPanelStyles from '../src/lib/forms/select/select-panel.component.css?inline';
7
+ import { CHECK_ICON } from '../src/lib/icon/headless/check-icon';
8
+ import { CHEVRON_ICON } from '../src/lib/icon/headless/chevron-icon';
9
+ import { TIMES_ICON } from '../src/lib/icon/headless/times-icon';
5
10
  import { FormFieldControlMock, formFieldStyles, FormFieldMockState } from './form-field';
11
+ import { iconMarkup } from './icon';
6
12
 
7
- export const CHECK_ICON_SVG = `<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/></svg>`;
13
+ export type SelectMockOption = { value: string; label: string; disabled?: boolean };
8
14
 
9
- export const CHEVRON_ICON_SVG = `<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M244.7 116.7c6.2-6.2 16.4-6.2 22.6 0l192 192c6.2 6.2 6.2 16.4 0 22.6s-16.4 6.2-22.6 0L256 150.6 75.3 331.3c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l192-192z"/></svg>`;
15
+ export type SelectMockActiveSource = 'keyboard' | 'pointer';
10
16
 
11
- export const TIMES_ICON_SVG = `<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M324.5 411.1c6.2 6.2 16.4 6.2 22.6 0s6.2-16.4 0-22.6L214.6 256 347.1 123.5c6.2-6.2 6.2-16.4 0-22.6s-16.4-6.2-22.6 0L192 233.4 59.6 100.9c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6L169.4 256 36.9 388.5c-6.2 6.2-6.2 16.4 0 22.6s16.4 6.2 22.6 0L192 278.6 324.5 411.1z"/></svg>`;
17
+ export type SelectOptionMockState = {
18
+ id: string;
19
+ selected?: boolean;
20
+ /** Where the active option came from: the keyboard draws it highlighted, the pointer only under `:hover`. */
21
+ active?: SelectMockActiveSource;
22
+ };
23
+
24
+ export type SelectAllOptionMockOptions = {
25
+ id: string;
26
+ label: string;
27
+ checked: boolean | 'mixed';
28
+ active?: SelectMockActiveSource;
29
+ };
30
+
31
+ export type SelectOptionGroupMockOptions = { labelId: string; label: string; content: string };
12
32
 
13
- export type SelectMockOption = { value: string; label: string };
33
+ export type SelectPanelMockOptions = {
34
+ id: string;
35
+ labelId?: string | null;
36
+ /** The panel's options, groups and select-all row as HTML. */
37
+ content: string;
38
+ multiple?: boolean;
39
+ /** The app's default color theme name: the panel provides it to the options. */
40
+ color?: string;
41
+ /** A surface theme name the app registers, for the panel. */
42
+ surface?: string;
43
+ };
14
44
 
15
45
  export type SelectMockOptions = {
16
46
  options: SelectMockOption[];
@@ -33,12 +63,70 @@ export const selectStyles = /* @__PURE__ */ [
33
63
  formFieldStyles,
34
64
  controlSuffix,
35
65
  selectBase,
36
- selectOption,
37
- selectPanel,
66
+ selectOptionStyles,
67
+ selectOptionGroupStyles,
68
+ selectAllOptionStyles,
69
+ selectPanelStyles,
38
70
  ].join('\n');
39
71
 
40
- const icon = (name: string, svg: string) =>
41
- `<i eticon="${name}" aria-hidden="true" class="et-icon et-icon--${name}">${svg}</i>`;
72
+ const activeAttributes = (active: SelectMockActiveSource | undefined) =>
73
+ active && `data-active="true" data-active-source="${active}"`;
74
+
75
+ /** Draws one `<et-select-option>` as the select panel renders it. */
76
+ export const selectOption = (option: SelectMockOption, state: SelectOptionMockState) => {
77
+ const attributes = [
78
+ 'role="option"',
79
+ `value="${option.value}"`,
80
+ 'class="et-select-option"',
81
+ `id="${state.id}"`,
82
+ `aria-selected="${!!state.selected}"`,
83
+ option.disabled && 'aria-disabled="true"',
84
+ state.selected && 'data-selected="true"',
85
+ activeAttributes(state.active),
86
+ ].filter(Boolean);
87
+
88
+ return `<et-select-option ${attributes.join(' ')}><span class="et-select-option-label">${option.label}</span><span class="et-select-option-check">${iconMarkup(CHECK_ICON)}</span></et-select-option>`;
89
+ };
90
+
91
+ /** Draws the `<et-select-all-option>` row a multi select with `selectAll` renders first in its panel. */
92
+ export const selectAllOption = (options: SelectAllOptionMockOptions) => {
93
+ const attributes = [
94
+ 'role="option"',
95
+ 'class="et-select-option et-select-all-option"',
96
+ `id="${options.id}"`,
97
+ `aria-checked="${options.checked}"`,
98
+ activeAttributes(options.active),
99
+ ].filter(Boolean);
100
+ const check =
101
+ options.checked === 'mixed'
102
+ ? '<span class="et-select-all-option-dash" aria-hidden="true"></span>'
103
+ : iconMarkup(CHECK_ICON);
104
+
105
+ return `<et-select-all-option ${attributes.join(' ')}><span class="et-select-option-label">${options.label}</span><span class="et-select-option-check">${check}</span></et-select-all-option>`;
106
+ };
107
+
108
+ /** Draws an `<et-select-option-group>` with its label around the option HTML in `content`. */
109
+ export const selectOptionGroup = (options: SelectOptionGroupMockOptions) =>
110
+ `<et-select-option-group role="group" aria-labelledby="${options.labelId}" class="et-select-option-group"><div id="${options.labelId}" class="et-select-option-group-label">${options.label}</div>${options.content}</et-select-option-group>`;
111
+
112
+ /** Draws the open `<et-select-panel>` with its listbox around the HTML in `content`. */
113
+ export const selectPanel = (options: SelectPanelMockOptions) => {
114
+ const listbox = [
115
+ 'role="listbox"',
116
+ 'class="et-select-listbox"',
117
+ `id="et-select-listbox-${options.id}"`,
118
+ options.labelId && `aria-labelledby="${options.labelId}"`,
119
+ options.multiple && 'aria-multiselectable="true"',
120
+ ].filter(Boolean);
121
+ const classes = [
122
+ 'et-select-panel',
123
+ 'et-color--inherited',
124
+ options.color && `et-color--${options.color}`,
125
+ `et-surface--${options.surface ?? 'inherited'}`,
126
+ ].filter(Boolean);
127
+
128
+ return `<et-select-panel class="${classes.join(' ')}"><div class="et-select-panel-scroller et-scrollbar-host"><div class="et-select-panel-body"><div ${listbox.join(' ')}>${options.content}</div><div class="et-select-panel-extras"></div></div></div><et-scrollbar class="et-scrollbar" data-orientation="vertical"><div class="et-scrollbar-thumb"></div></et-scrollbar></et-select-panel>`;
129
+ };
42
130
 
43
131
  /**
44
132
  * Draws `<et-select>` as a control for `formField()`. Pass `open: true` to `formField()` to draw
@@ -47,21 +135,12 @@ const icon = (name: string, svg: string) =>
47
135
  export const select = (options: SelectMockOptions): FormFieldControlMock => {
48
136
  const selected = options.options.find((option) => option.value === options.value);
49
137
 
50
- const optionHtml = (state: FormFieldMockState) => (option: SelectMockOption, index: number) => {
51
- const isSelected = option === selected;
52
- const isActive = state.open && option.value === options.active;
53
- const attributes = [
54
- 'role="option"',
55
- `value="${option.value}"`,
56
- 'class="et-select-option"',
57
- `id="et-select-option-${state.id}-${index}"`,
58
- `aria-selected="${isSelected}"`,
59
- isSelected && 'data-selected="true"',
60
- isActive && 'data-active="true" data-active-source="keyboard"',
61
- ].filter(Boolean);
62
-
63
- return `<et-select-option ${attributes.join(' ')}><span class="et-select-option-label">${option.label}</span><span class="et-select-option-check">${icon('et-check', CHECK_ICON_SVG)}</span></et-select-option>`;
64
- };
138
+ const optionHtml = (state: FormFieldMockState) => (option: SelectMockOption, index: number) =>
139
+ selectOption(option, {
140
+ id: `et-select-option-${state.id}-${index}`,
141
+ selected: option === selected,
142
+ active: state.open && option.value === options.active ? 'keyboard' : undefined,
143
+ });
65
144
 
66
145
  const optionsHtml = (state: FormFieldMockState) => options.options.map(optionHtml(state)).join('');
67
146
 
@@ -99,12 +178,18 @@ export const select = (options: SelectMockOptions): FormFieldControlMock => {
99
178
  suffix: (state) => {
100
179
  const clear =
101
180
  (options.clearable ?? true) && selected && state.focused && !state.disabled && !state.readonly
102
- ? `<button aria-label="${options.clearLabel ?? 'Clear'}" class="et-input-clear" tabindex="-1" type="button">${icon('et-times', TIMES_ICON_SVG)}</button>`
181
+ ? `<button aria-label="${options.clearLabel ?? 'Clear'}" class="et-input-clear" tabindex="-1" type="button">${iconMarkup(TIMES_ICON)}</button>`
103
182
  : '';
104
183
 
105
- return `${clear}<button aria-hidden="true" tabindex="-1" type="button" class="et-input-picker-trigger et-select-arrow"${state.disabled ? ' disabled' : ''}>${icon('et-chevron', CHEVRON_ICON_SVG)}</button>`;
184
+ return `${clear}<button aria-hidden="true" tabindex="-1" type="button" class="et-input-picker-trigger et-select-arrow"${state.disabled ? ' disabled' : ''}>${iconMarkup(CHEVRON_ICON)}</button>`;
106
185
  },
107
186
  panel: (state) =>
108
- `<et-select-panel class="et-select-panel et-color--inherited${options.panelColor ? ` et-color--${options.panelColor}` : ''} et-surface--${options.panelSurface ?? 'inherited'}"><div class="et-select-panel-scroller et-scrollbar-host"><div class="et-select-panel-body"><div role="listbox" class="et-select-listbox" id="et-select-listbox-${state.id}"${state.labelId ? ` aria-labelledby="${state.labelId}"` : ''}>${optionsHtml(state)}</div><div class="et-select-panel-extras"></div></div></div><et-scrollbar class="et-scrollbar" data-orientation="vertical"><div class="et-scrollbar-thumb"></div></et-scrollbar></et-select-panel>`,
187
+ selectPanel({
188
+ id: state.id,
189
+ labelId: state.labelId,
190
+ content: optionsHtml(state),
191
+ color: options.panelColor,
192
+ surface: options.panelSurface,
193
+ }),
109
194
  };
110
195
  };
package/design/tree.ts ADDED
@@ -0,0 +1,106 @@
1
+ import iconStyles from '../src/lib/icon/headless/icon-styles.component.css?inline';
2
+ import treeBase from '../src/lib/tree/tree.component.css?inline';
3
+ import { CHEVRON_ICON } from '../src/lib/icon/headless/chevron-icon';
4
+ import { iconMarkup } from './icon';
5
+
6
+ export type TreeMockItem = {
7
+ label: string;
8
+ /** Defaults to the slash-joined labels of the item and its parents. */
9
+ value?: string;
10
+ /** Left out, the item is a leaf. */
11
+ children?: TreeMockItem[];
12
+ disabled?: boolean;
13
+ };
14
+
15
+ export type TreeMockOptions = {
16
+ items: TreeMockItem[];
17
+ /** The tree's `aria-label`. */
18
+ label?: string;
19
+ /** The values of the expanded branches. */
20
+ expanded?: string[];
21
+ /** The selected value, or values in `multiple` mode. */
22
+ value?: string | string[];
23
+ /** Defaults to `single`. */
24
+ selectionMode?: 'none' | 'single' | 'multiple';
25
+ /** The value of the row that holds the tab stop. Defaults to the first row. */
26
+ active?: string;
27
+ disabled?: boolean;
28
+ /** Extra attributes on a row's element, by row value, e.g. design markers. */
29
+ rowAttributes?: Record<string, string>;
30
+ };
31
+
32
+ type MockRow = { item: TreeMockItem; value: string; level: number; posInSet: number; setSize: number };
33
+
34
+ /** The stylesheets `tree()` draws with: the real `et-tree` CSS. */
35
+ export const treeStyles = /* @__PURE__ */ [iconStyles, treeBase].join('\n');
36
+
37
+ const valueOf = (item: TreeMockItem, parent: string | null) =>
38
+ item.value ?? (parent ? `${parent}/${item.label}` : item.label);
39
+
40
+ const visibleRows = (
41
+ items: TreeMockItem[],
42
+ { expanded, parent, level }: { expanded: Set<string>; parent: string | null; level: number },
43
+ ): MockRow[] =>
44
+ items.flatMap((item, index) => {
45
+ const value = valueOf(item, parent);
46
+ const row = { item, value, level, posInSet: index + 1, setSize: items.length };
47
+ const children =
48
+ item.children && expanded.has(value)
49
+ ? visibleRows(item.children, { expanded, parent: value, level: level + 1 })
50
+ : [];
51
+
52
+ return [row, ...children];
53
+ });
54
+
55
+ const marker = (isBranch: boolean) =>
56
+ `<et-tree-marker aria-hidden="true" class="et-tree-node-marker">${
57
+ isBranch ? iconMarkup(CHEVRON_ICON, 'et-tree-node-chevron') : ''
58
+ }</et-tree-marker>`;
59
+
60
+ /** Draws `<et-tree>` with the rows its `visibleRows()` would render for `items`. */
61
+ export const tree = (options: TreeMockOptions) => {
62
+ const mode = options.selectionMode ?? 'single';
63
+ const expanded = new Set(options.expanded ?? []);
64
+ const selected = new Set(
65
+ options.value === undefined ? [] : Array.isArray(options.value) ? options.value : [options.value],
66
+ );
67
+ const rows = visibleRows(options.items, { expanded, parent: null, level: 1 });
68
+ const active = rows.some((row) => row.value === options.active) ? options.active : rows[0]?.value;
69
+
70
+ const rowHtml = (row: MockRow) => {
71
+ const isBranch = row.item.children !== undefined;
72
+ const isExpanded = isBranch && expanded.has(row.value);
73
+ const isSelected = mode !== 'none' && selected.has(row.value);
74
+ const isDisabled = !!row.item.disabled || !!options.disabled;
75
+ const attributes = [
76
+ options.rowAttributes?.[row.value],
77
+ `style="--_et-tree-node-depth: ${row.level - 1};"`,
78
+ 'class="et-tree-node"',
79
+ 'role="treeitem"',
80
+ `aria-level="${row.level}"`,
81
+ `aria-posinset="${row.posInSet}"`,
82
+ `aria-setsize="${row.setSize}"`,
83
+ isBranch && `aria-expanded="${isExpanded}"`,
84
+ mode !== 'none' && `aria-selected="${isSelected}"`,
85
+ isDisabled && 'aria-disabled="true"',
86
+ `tabindex="${row.value === active ? 0 : -1}"`,
87
+ isSelected && 'data-selected="true"',
88
+ isExpanded && 'data-expanded="true"',
89
+ isBranch && 'data-branch="true"',
90
+ isDisabled && 'data-disabled="true"',
91
+ ].filter(Boolean);
92
+ const check = mode === 'multiple' ? '<span aria-hidden="true" class="et-tree-node-check"></span>' : '';
93
+
94
+ return `<div ${attributes.join(' ')}>${marker(isBranch)}${check}<span class="et-tree-node-label">${row.item.label}</span></div>`;
95
+ };
96
+
97
+ const hostAttributes = [
98
+ 'class="et-tree"',
99
+ 'role="tree"',
100
+ options.label && `aria-label="${options.label}"`,
101
+ mode === 'multiple' && 'aria-multiselectable="true"',
102
+ options.disabled && 'data-disabled="true"',
103
+ ].filter(Boolean);
104
+
105
+ return `<et-tree ${hostAttributes.join(' ')}>${rows.map(rowHtml).join('')}</et-tree>`;
106
+ };