@ultimat3/ui 1.0.0 → 1.2.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.
@@ -10,9 +10,7 @@
10
10
  color: t.role('fg');
11
11
  box-shadow: t.shadow(xl);
12
12
 
13
- &::backdrop {
14
- background: t.role('scrim', 0.45);
15
- }
13
+ @include t.scrim-backdrop;
16
14
  }
17
15
 
18
16
  .side-inline-start {
@@ -10,10 +10,7 @@
10
10
  color: t.role('fg-muted');
11
11
  cursor: pointer;
12
12
 
13
- &:disabled {
14
- opacity: 0.55;
15
- cursor: not-allowed;
16
- }
13
+ @include t.disabled;
17
14
  }
18
15
 
19
16
  .round {
@@ -44,39 +41,12 @@
44
41
  font-size: t.text(lg);
45
42
  }
46
43
 
47
- .tone-neutral {
48
- --tone: #{t.role('fg-strong')};
49
- --tone-soft: #{t.role('bg-soft')};
50
- }
51
-
52
- .tone-accent {
53
- --tone: #{t.role('accent')};
54
- --tone-soft: #{t.role('accent', 0.12)};
55
- }
56
-
57
- .tone-success {
58
- --tone: #{t.role('success')};
59
- --tone-soft: #{t.role('success-soft')};
60
- }
61
-
62
- .tone-warning {
63
- --tone: #{t.role('warning')};
64
- --tone-soft: #{t.role('warning-soft')};
65
- }
66
-
67
- .tone-danger {
68
- --tone: #{t.role('danger')};
69
- --tone-soft: #{t.role('danger-soft')};
70
- }
71
-
72
- .tone-info {
73
- --tone: #{t.role('info')};
74
- --tone-soft: #{t.role('info-soft')};
75
- }
44
+ @include t.tone-classes;
76
45
 
77
46
  .variant-primary {
78
47
  background: var(--tone);
79
- color: t.role('accent-fg');
48
+ // `--tone-fg` rather than a hardcoded accent-fg: a danger IconButton needs danger's on-colour.
49
+ color: var(--tone-fg);
80
50
  }
81
51
 
82
52
  .variant-secondary {
@@ -0,0 +1,47 @@
1
+ @use '../tokens' as t;
2
+
3
+ .pageHeader {
4
+ @include t.column(t.space(3));
5
+ padding-block-end: t.space(5);
6
+ border-block-end: 1px solid t.role('line');
7
+ }
8
+
9
+ // Wraps rather than shrinking: a long title must never squeeze the actions off the row.
10
+ .bar {
11
+ @include t.row(t.space(4), flex-start, space-between);
12
+ flex-wrap: wrap;
13
+ }
14
+
15
+ .titles {
16
+ @include t.row(t.space(3), flex-start);
17
+ min-inline-size: 0;
18
+ flex: 1 1 20rem;
19
+ }
20
+
21
+ .media {
22
+ display: inline-flex;
23
+ flex: none;
24
+ }
25
+
26
+ .text {
27
+ @include t.column(t.space(1));
28
+ min-inline-size: 0;
29
+ }
30
+
31
+ .title {
32
+ color: t.role('fg-strong');
33
+ font-size: t.text(2xl);
34
+ line-height: t.leading(tight);
35
+ }
36
+
37
+ .description {
38
+ color: t.role('fg-muted');
39
+ font-size: t.text(sm);
40
+ max-inline-size: 68ch;
41
+ }
42
+
43
+ .actions {
44
+ @include t.row(t.space(2), center, flex-end);
45
+ flex-wrap: wrap;
46
+ flex: none;
47
+ }
@@ -0,0 +1,46 @@
1
+ // The top of a screen: breadcrumbs, the page's one heading, a description, and the actions that
2
+ // belong to the page rather than to a row in it. Hand-rolled, this is where the heading level,
3
+ // the landmark and the action alignment go wrong; here it is one component with one shape.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import { Breadcrumb, type BreadcrumbItem } from './Breadcrumb';
8
+ import { type HeadingLevel, headingTag } from './heading-level';
9
+ import styles from './PageHeader.module.scss';
10
+
11
+ export interface PageHeaderProps {
12
+ /** Already-translated page title. Rendered as the heading, once. */
13
+ title: string;
14
+ /** Already-translated supporting line under the title. */
15
+ description?: string | undefined;
16
+ /** Trailing controls — a Button, a Toolbar. Wraps under the title on narrow viewports. */
17
+ actions?: JSX.Element | undefined;
18
+ breadcrumbs?: readonly BreadcrumbItem[] | undefined;
19
+ /** Heading level. 1 by default: a screen has exactly one h1, and this is it. */
20
+ level?: HeadingLevel | undefined;
21
+ /** Rendered before the title — an avatar, an icon, a status dot. */
22
+ media?: JSX.Element | undefined;
23
+ class?: string | undefined;
24
+ }
25
+
26
+ export function PageHeader(props: PageHeaderProps): JSX.Element {
27
+ const Heading = headingTag(props.level ?? 1);
28
+
29
+ return (
30
+ <header class={cx(styles['pageHeader'], props.class)}>
31
+ {props.breadcrumbs === undefined ? null : <Breadcrumb items={props.breadcrumbs} />}
32
+ <div class={styles['bar']}>
33
+ <div class={styles['titles']}>
34
+ {props.media === undefined ? null : <div class={styles['media']}>{props.media}</div>}
35
+ <div class={styles['text']}>
36
+ <Heading class={styles['title']}>{props.title}</Heading>
37
+ {props.description === undefined ? null : (
38
+ <p class={styles['description']}>{props.description}</p>
39
+ )}
40
+ </div>
41
+ </div>
42
+ {props.actions === undefined ? null : <div class={styles['actions']}>{props.actions}</div>}
43
+ </div>
44
+ </header>
45
+ );
46
+ }
@@ -0,0 +1,38 @@
1
+ @use '../tokens' as t;
2
+
3
+ .section {
4
+ @include t.column(t.space(4));
5
+ min-inline-size: 0;
6
+ }
7
+
8
+ .head {
9
+ @include t.row(t.space(4), flex-end, space-between);
10
+ flex-wrap: wrap;
11
+ }
12
+
13
+ .text {
14
+ @include t.column(t.space(1));
15
+ min-inline-size: 0;
16
+ }
17
+
18
+ .title {
19
+ color: t.role('fg-strong');
20
+ font-size: t.text(lg);
21
+ line-height: t.leading(snug);
22
+ }
23
+
24
+ .description {
25
+ color: t.role('fg-muted');
26
+ font-size: t.text(sm);
27
+ max-inline-size: 68ch;
28
+ }
29
+
30
+ .actions {
31
+ @include t.row(t.space(2), center, flex-end);
32
+ flex-wrap: wrap;
33
+ flex: none;
34
+ }
35
+
36
+ .body {
37
+ min-inline-size: 0;
38
+ }
@@ -0,0 +1,55 @@
1
+ // A titled block inside a page. Exists so a screen's second-level structure is a labelled
2
+ // landmark with a real heading — `aria-labelledby` wired to the heading it renders — instead of
3
+ // a <div> with bold text, which is what an unassisted layout always becomes.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { useId } from '../a11y';
7
+ import { cx } from '../cx';
8
+ import { type HeadingLevel, headingTag } from './heading-level';
9
+ import styles from './Section.module.scss';
10
+
11
+ export interface SectionProps {
12
+ children: JSX.Element;
13
+ /** Already-translated section title. Omit for an unlabelled grouping. */
14
+ title?: string | undefined;
15
+ /** Already-translated supporting line under the title. */
16
+ description?: string | undefined;
17
+ /** Controls that act on this section only. */
18
+ actions?: JSX.Element | undefined;
19
+ /** Heading level. 2 by default — the level under a PageHeader's h1. */
20
+ level?: HeadingLevel | undefined;
21
+ as?: 'section' | 'article' | 'aside' | undefined;
22
+ class?: string | undefined;
23
+ }
24
+
25
+ export function Section(props: SectionProps): JSX.Element {
26
+ const Tag = props.as ?? 'section';
27
+ const Heading = headingTag(props.level ?? 2);
28
+ const titleId = useId('section');
29
+
30
+ return (
31
+ <Tag
32
+ class={cx(styles['section'], props.class)}
33
+ aria-labelledby={props.title === undefined ? undefined : titleId}
34
+ >
35
+ {props.title === undefined && props.actions === undefined ? null : (
36
+ <div class={styles['head']}>
37
+ <div class={styles['text']}>
38
+ {props.title === undefined ? null : (
39
+ <Heading id={titleId} class={styles['title']}>
40
+ {props.title}
41
+ </Heading>
42
+ )}
43
+ {props.description === undefined ? null : (
44
+ <p class={styles['description']}>{props.description}</p>
45
+ )}
46
+ </div>
47
+ {props.actions === undefined ? null : (
48
+ <div class={styles['actions']}>{props.actions}</div>
49
+ )}
50
+ </div>
51
+ )}
52
+ <div class={styles['body']}>{props.children}</div>
53
+ </Tag>
54
+ );
55
+ }
@@ -49,10 +49,7 @@
49
49
  background: t.role('bg-soft');
50
50
  }
51
51
 
52
- &:disabled {
53
- opacity: 0.5;
54
- cursor: not-allowed;
55
- }
52
+ @include t.disabled(0.5);
56
53
 
57
54
  &[aria-selected='true'] {
58
55
  color: t.role('accent');
@@ -0,0 +1,26 @@
1
+ @use '../tokens' as t;
2
+
3
+ .toolbar {
4
+ @include t.row(t.space(3), center, space-between);
5
+ flex-wrap: wrap;
6
+ min-inline-size: 0;
7
+ }
8
+
9
+ .surface {
10
+ @include t.surface('surface', 'xs');
11
+ padding-block: t.space(3);
12
+ padding-inline: t.space(4);
13
+ }
14
+
15
+ .start {
16
+ @include t.row(t.space(2), center);
17
+ flex: 1 1 auto;
18
+ flex-wrap: wrap;
19
+ min-inline-size: 0;
20
+ }
21
+
22
+ .end {
23
+ @include t.row(t.space(2), center, flex-end);
24
+ flex: none;
25
+ flex-wrap: wrap;
26
+ }
@@ -0,0 +1,48 @@
1
+ // The control strip above a table or a list: filters and search at the inline start, actions at
2
+ // the inline end. `role="toolbar"` with the same roving-tabindex helper Tabs uses, so arrow keys
3
+ // move between controls and the strip costs one Tab stop instead of a dozen.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { createRovingTabindex, focusableWithin } from '../a11y';
7
+ import { cx } from '../cx';
8
+ import { useUi } from '../theme/context';
9
+ import styles from './Toolbar.module.scss';
10
+
11
+ export interface ToolbarProps {
12
+ /** Leading controls — search, filters, a Select. */
13
+ children: JSX.Element;
14
+ /** Trailing controls, pushed to the inline end. */
15
+ actions?: JSX.Element | undefined;
16
+ /** Already-translated accessible name. Required: an unnamed toolbar is an unnamed group. */
17
+ label: string;
18
+ /** Renders the strip on a raised, bordered surface. */
19
+ surface?: boolean | undefined;
20
+ class?: string | undefined;
21
+ }
22
+
23
+ export function Toolbar(props: ToolbarProps): JSX.Element {
24
+ const ui = useUi();
25
+ let strip: HTMLDivElement | undefined;
26
+
27
+ // Arrows follow the writing direction; `focusableWithin` skips anything hidden or disabled.
28
+ const onKeyDown = createRovingTabindex(
29
+ () => (strip === undefined ? [] : focusableWithin(strip)),
30
+ { orientation: 'horizontal', dir: ui.dir, loop: false },
31
+ );
32
+
33
+ return (
34
+ <div
35
+ ref={(el: HTMLDivElement) => {
36
+ strip = el;
37
+ }}
38
+ class={cx(styles['toolbar'], props.surface === true && styles['surface'], props.class)}
39
+ role="toolbar"
40
+ aria-label={props.label}
41
+ aria-orientation="horizontal"
42
+ onKeyDown={onKeyDown}
43
+ >
44
+ <div class={styles['start']}>{props.children}</div>
45
+ {props.actions === undefined ? null : <div class={styles['end']}>{props.actions}</div>}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,35 @@
1
+ // AppShell's two rules, kept away from the markup: the id the skip link points at is derived
2
+ // from the same place the <main> gets it, and which landmarks the frame emits in which order.
3
+ // Both are silent failures when wrong — a skip link to nowhere, or two <main> elements.
4
+
5
+ export type ShellLandmark = 'banner' | 'navigation' | 'main' | 'contentinfo';
6
+
7
+ export interface ShellIds {
8
+ readonly mainId: string;
9
+ /** Always `#` + `mainId`. One derivation, so the link and the target cannot drift. */
10
+ readonly skipHref: string;
11
+ }
12
+
13
+ export interface ShellSlots {
14
+ readonly header: boolean;
15
+ readonly sidebar: boolean;
16
+ readonly footer: boolean;
17
+ }
18
+
19
+ export function shellIds(base: string): ShellIds {
20
+ const mainId = `${base}-main`;
21
+ return { mainId, skipHref: `#${mainId}` };
22
+ }
23
+
24
+ /**
25
+ * DOM order, which is also screen-reader order: banner, then navigation, then main, then
26
+ * contentinfo. `main` is unconditional — a shell with no main region is not a page.
27
+ */
28
+ export function shellLandmarks(slots: ShellSlots): readonly ShellLandmark[] {
29
+ const out: ShellLandmark[] = [];
30
+ if (slots.header) out.push('banner');
31
+ if (slots.sidebar) out.push('navigation');
32
+ out.push('main');
33
+ if (slots.footer) out.push('contentinfo');
34
+ return out;
35
+ }
@@ -0,0 +1,25 @@
1
+ // Which heading element a composite renders. A rule, not markup: PageHeader and Section both
2
+ // need it, and a page whose headings skip a level is a screen-reader outline with holes — so the
3
+ // level is a prop with one mapping, never a hardcoded <h2> inside a component.
4
+
5
+ import { invalidValueError } from '../errors';
6
+
7
+ export const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const;
8
+ export type HeadingLevel = (typeof HEADING_LEVELS)[number];
9
+ export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
10
+
11
+ /** `2` → `'h2'`. Throws `X_UI_INVALID_VALUE` for anything off the scale. */
12
+ export function headingTag(level: HeadingLevel): HeadingTag {
13
+ if (!(HEADING_LEVELS as readonly number[]).includes(level)) {
14
+ throw invalidValueError('heading', level, 'a heading level from 1 to 6');
15
+ }
16
+ return `h${level}` as HeadingTag;
17
+ }
18
+
19
+ /**
20
+ * The level for content nested under a heading. Clamped at 6 rather than throwing: a deeply
21
+ * nested Section is a layout the app is allowed to build, and HTML has no `<h7>`.
22
+ */
23
+ export function nextHeadingLevel(level: HeadingLevel): HeadingLevel {
24
+ return (level < 6 ? level + 1 : 6) as HeadingLevel;
25
+ }
package/src/errors.ts CHANGED
@@ -35,12 +35,39 @@ export class UiError extends UltimateError {
35
35
  }
36
36
  }
37
37
 
38
- /** A component asked for a token role that the SCSS source does not define. */
39
- export function unknownTokenError(kind: string, name: string, known: readonly string[]): UiError {
38
+ /**
39
+ * A component asked for a token role that the SCSS source does not define. `source` is the
40
+ * partial that declares the scale — defaulted, because most kinds pluralise (`colors`), and
41
+ * named explicitly by the ones that do not (`radius`), so the `fix:` is always a real path.
42
+ */
43
+ export function unknownTokenError(
44
+ kind: string,
45
+ name: string,
46
+ known: readonly string[],
47
+ source = `_${kind}s.scss`,
48
+ ): UiError {
40
49
  return new UiError({
41
50
  code: UI_ERROR_CODES.tokenUnknown,
42
51
  cause: `unknown ${kind} token "${name}"; known roles: ${known.join(', ')}`,
43
- fix: `use one of the ${kind} roles above, or add "${name}" to packages/ui/src/tokens/_${kind}s.scss and mirror it in tokens.ts`,
52
+ fix: `use one of the ${kind} roles above, or add "${name}" to packages/ui/src/tokens/${source} and mirror it in tokens.ts`,
53
+ });
54
+ }
55
+
56
+ /**
57
+ * A `defineTheme()` override held something that is not a token value. Strict on purpose: the
58
+ * result is interpolated into a `<style>` element, so a value carrying `;`, `}` or `</style>` is
59
+ * a CSS injection, not a typo.
60
+ */
61
+ export function invalidBrandTokenError(
62
+ scope: string,
63
+ name: string,
64
+ value: unknown,
65
+ expected: string,
66
+ ): UiError {
67
+ return new UiError({
68
+ code: UI_ERROR_CODES.invalidValue,
69
+ cause: `defineTheme() override ${scope}.${name} is ${JSON.stringify(value)}, which is not ${expected}`,
70
+ fix: `pass ${expected} to defineTheme(), e.g. defineTheme({ colors: { light: { accent: '31 110 178' } } })`,
44
71
  });
45
72
  }
46
73
 
package/src/i18n-keys.ts CHANGED
@@ -17,6 +17,10 @@ export const UI_KEYS = {
17
17
  sortAscending: 'ui.sort.ascending',
18
18
  sortDescending: 'ui.sort.descending',
19
19
  breadcrumb: 'ui.breadcrumb',
20
+ /** AppShell's skip link — the first Tab stop on every page. */
21
+ skip: 'ui.skip',
22
+ /** AppShell's sidebar landmark name. */
23
+ navigation: 'ui.navigation',
20
24
  menu: 'ui.menu',
21
25
  more: 'ui.more',
22
26
  required: 'ui.required',
package/src/index.ts CHANGED
@@ -21,8 +21,12 @@ export {
21
21
  export type { AlertProps } from './components/Alert';
22
22
  // --- components --------------------------------------------------------------
23
23
  export { Alert } from './components/Alert';
24
+ export type { AppShellProps } from './components/AppShell';
25
+ export { AppShell } from './components/AppShell';
24
26
  export type { AvatarProps } from './components/Avatar';
25
27
  export { Avatar, initialsOf } from './components/Avatar';
28
+ export type { ShellIds, ShellLandmark, ShellSlots } from './components/app-shell-view';
29
+ export { shellIds, shellLandmarks } from './components/app-shell-view';
26
30
  export type { BadgeProps } from './components/Badge';
27
31
  export { Badge } from './components/Badge';
28
32
  export type { BreadcrumbItem, BreadcrumbProps } from './components/Breadcrumb';
@@ -63,6 +67,8 @@ export type { FormProps } from './components/Form';
63
67
  export { Form } from './components/Form';
64
68
  export type { GridProps } from './components/Grid';
65
69
  export { Grid } from './components/Grid';
70
+ export type { HeadingLevel, HeadingTag } from './components/heading-level';
71
+ export { HEADING_LEVELS, headingTag, nextHeadingLevel } from './components/heading-level';
66
72
  export type { IconButtonProps } from './components/IconButton';
67
73
  export { IconButton } from './components/IconButton';
68
74
  export type { ImageProps } from './components/Image';
@@ -82,6 +88,8 @@ export { Money } from './components/Money';
82
88
  export type { MoneyFormatter, MoneyInput, MoneyViewOptions } from './components/money-view';
83
89
  // --- formatting cores (pure, renderer-free) ----------------------------------
84
90
  export { moneyText, toMoney } from './components/money-view';
91
+ export type { PageHeaderProps } from './components/PageHeader';
92
+ export { PageHeader } from './components/PageHeader';
85
93
  export type { PaginationProps } from './components/Pagination';
86
94
  export { Pagination } from './components/Pagination';
87
95
  export type { Placement, PopoverProps } from './components/Popover';
@@ -92,6 +100,8 @@ export type { RelativeTimeProps } from './components/RelativeTime';
92
100
  export { RelativeTime } from './components/RelativeTime';
93
101
  export type { RelativeTimeOptions } from './components/relative-time-view';
94
102
  export { relativeTimeText } from './components/relative-time-view';
103
+ export type { SectionProps } from './components/Section';
104
+ export { Section } from './components/Section';
95
105
  export type { SelectOption, SelectProps } from './components/Select';
96
106
  export { Select } from './components/Select';
97
107
  export type { SkeletonProps } from './components/Skeleton';
@@ -116,6 +126,8 @@ export type { ThemeChoice, ThemeToggleProps } from './components/ThemeToggle';
116
126
  export { ThemeToggle } from './components/ThemeToggle';
117
127
  export type { ToastProps, ToastRegionProps } from './components/Toast';
118
128
  export { Toast, ToastRegion } from './components/Toast';
129
+ export type { ToolbarProps } from './components/Toolbar';
130
+ export { Toolbar } from './components/Toolbar';
119
131
  export type { TooltipProps } from './components/Tooltip';
120
132
  export { Tooltip } from './components/Tooltip';
121
133
  export type { Align, ButtonVariant, Size, SpaceStep, Tone } from './components/variants';
@@ -126,6 +138,7 @@ export type { ClassValue } from './cx';
126
138
  export { cx } from './cx';
127
139
  export type { UiErrorCode } from './errors';
128
140
  export {
141
+ invalidBrandTokenError,
129
142
  invalidThemeError,
130
143
  invalidValueError,
131
144
  runtimeMissingError,
@@ -135,6 +148,8 @@ export {
135
148
  } from './errors';
136
149
  export type { UiKey } from './i18n-keys';
137
150
  export { UI_KEYS } from './i18n-keys';
151
+ export type { Brand, BrandInput, FontSlot } from './theme/brand';
152
+ export { brandStyleTag, defineTheme, FONT_SLOTS } from './theme/brand';
138
153
  export type { Direction, UiContextValue } from './theme/context';
139
154
  export {
140
155
  defaultUiContext,
@@ -177,7 +192,18 @@ export {
177
192
  toggleTheme,
178
193
  watchOsTheme,
179
194
  } from './theme/theme';
180
- export type { ColorRole, Theme } from './tokens/tokens';
195
+ export type { Channels } from './tokens/contrast';
196
+ export {
197
+ AA_LARGE,
198
+ AA_TEXT,
199
+ CHANNELS_PATTERN,
200
+ contrastRatio,
201
+ meetsContrast,
202
+ parseChannels,
203
+ relativeLuminance,
204
+ roleContrast,
205
+ } from './tokens/contrast';
206
+ export type { ColorRole, RadiusName, Theme } from './tokens/tokens';
181
207
  // --- tokens ------------------------------------------------------------------
182
208
  export {
183
209
  assertColorRole,