@jetbrains/ring-ui 7.0.121 → 7.0.123-beta.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.
- package/README.md +4 -0
- package/components/collapse/collapse-content.d.ts +7 -0
- package/components/collapse/collapse-content.js +48 -6
- package/components/collapse/collapse-context.d.ts +1 -0
- package/components/collapse/collapse-context.js +1 -0
- package/components/collapse/collapse.d.ts +9 -0
- package/components/collapse/collapse.js +2 -1
- package/components/collapsible-group/collapsible-group.css +6 -0
- package/components/collapsible-group/collapsible-group.d.ts +13 -0
- package/components/collapsible-group/collapsible-group.js +4 -3
- package/components/expand/collapsible-group.css +4 -0
- package/package.json +4 -2
- package/skills/ring-ui/SKILL.md +43 -0
- package/skills/ring-ui/references/forms-and-feedback.md +261 -0
- package/skills/ring-ui/references/page-layouts.md +324 -0
package/README.md
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
This collection of UI components aims to provide all the necessary building blocks for web-based products built inside JetBrains, as well as third-party plugins developed for JetBrains' products.
|
|
15
15
|
|
|
16
|
+
## Guidance for coding agents
|
|
17
|
+
|
|
18
|
+
The npm packages include [`skills/ring-ui/SKILL.md`](./skills/ring-ui/SKILL.md), which teaches coding agents to consume Ring UI through `@jetbrains/ring-ui-built` when composing complete pages, responsive layouts, forms, themes, and feedback. The same Skill files are published in both npm artifacts.
|
|
19
|
+
|
|
16
20
|
## Try now
|
|
17
21
|
* Try the [codesandbox](https://codesandbox.io/p/sandbox/ring-ui-7-0-demo-z6v6ym), based on `create-react-app` tooling, to see and try the UI components
|
|
18
22
|
* Check out [list of examples](https://jetbrains.github.io/ring-ui/master/index.html) for each component
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import React, { type PropsWithChildren } from 'react';
|
|
2
2
|
interface Props {
|
|
3
|
+
/**
|
|
4
|
+
* Height of the always-visible preview of the collapsed content.
|
|
5
|
+
* The collapsed subtree is inert until expanded, matching `aria-expanded`.
|
|
6
|
+
* The preview is only a visual clip of that subtree, so it is inert too and
|
|
7
|
+
* assistive technology will not perceive it — if the preview carries essential
|
|
8
|
+
* information, expose an accessible summary outside the collapsed content.
|
|
9
|
+
*/
|
|
3
10
|
minHeight?: number;
|
|
4
11
|
className?: string;
|
|
5
12
|
'data-test'?: string | null | undefined;
|
|
@@ -10,11 +10,21 @@ const DURATION_FACTOR = 0.5;
|
|
|
10
10
|
const DEFAULT_HEIGHT = 0;
|
|
11
11
|
const VISIBLE = 1;
|
|
12
12
|
const HIDDEN = 0;
|
|
13
|
+
// margin for the hide fallback timer, so it fires only if transitionend never did
|
|
14
|
+
const HIDE_FALLBACK_EXTRA_DELAY = 100;
|
|
15
|
+
// React 18 renders unknown attributes from strings only, while React 19 treats `inert`
|
|
16
|
+
// as a real boolean and removes the attribute when it gets ''
|
|
17
|
+
// TODO drop the React 18 branch in develop-8.0 — Ring UI 8.0 supports React 19 only
|
|
18
|
+
const REACT_MAJOR_WITH_INERT_SUPPORT = 19;
|
|
19
|
+
const getInertAttributeValue = (reactMajor) => reactMajor >= REACT_MAJOR_WITH_INERT_SUPPORT ? true : '';
|
|
20
|
+
// the cast is confined to the JSX assignment: React 18 actually receives the string form
|
|
21
|
+
const INERT = getInertAttributeValue(Number(React.version.split('.')[0]));
|
|
22
|
+
const isFullyCollapsed = (collapsed, initialContentHeight) => collapsed && initialContentHeight <= DEFAULT_HEIGHT;
|
|
13
23
|
/**
|
|
14
24
|
* @name CollapseContent
|
|
15
25
|
*/
|
|
16
26
|
export const CollapseContent = ({ children, minHeight = DEFAULT_HEIGHT, 'data-test': dataTest, }) => {
|
|
17
|
-
const { collapsed, duration, id, disableAnimation } = useContext(CollapseContext);
|
|
27
|
+
const { collapsed, duration, id, disableAnimation, keepMounted } = useContext(CollapseContext);
|
|
18
28
|
const containerRef = useRef(null);
|
|
19
29
|
const contentRef = useRef(null);
|
|
20
30
|
const [initialContentHeight] = useState(minHeight);
|
|
@@ -23,20 +33,42 @@ export const CollapseContent = ({ children, minHeight = DEFAULT_HEIGHT, 'data-te
|
|
|
23
33
|
const height = toPx(nextHeight);
|
|
24
34
|
const [shouldHideContent, setShouldHideContent] = useState(collapsed && minHeight <= DEFAULT_HEIGHT);
|
|
25
35
|
useEffect(() => {
|
|
26
|
-
|
|
36
|
+
const container = containerRef.current;
|
|
37
|
+
function finalizeCollapse() {
|
|
27
38
|
if (initialContentHeight <= DEFAULT_HEIGHT) {
|
|
28
39
|
setShouldHideContent(collapsed);
|
|
29
40
|
}
|
|
30
41
|
}
|
|
31
|
-
|
|
42
|
+
function onTransitionEnd(event) {
|
|
43
|
+
// transitionend bubbles: only the container's own height transition finalizes the collapse
|
|
44
|
+
if (event.target === container && event.propertyName === 'height') {
|
|
45
|
+
finalizeCollapse();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
32
48
|
container?.addEventListener('transitionend', onTransitionEnd);
|
|
49
|
+
// fallback for suppressed or cancelled transitions (e.g. `transition: none` overrides),
|
|
50
|
+
// which emit no transitionend and would otherwise leave the content interactive forever.
|
|
51
|
+
// Armed once per collapse toggle from the --duration committed to the DOM — the value
|
|
52
|
+
// the running transition actually uses — so it can neither undercut a transition started
|
|
53
|
+
// from a stale height nor be restarted by content resizes or duration changes mid-collapse
|
|
54
|
+
// TODO merge with global/parse-css-duration when this lands in develop-8.0
|
|
55
|
+
const cssDuration = parseFloat(container?.style.getPropertyValue('--duration') || '') || 0;
|
|
56
|
+
const fallbackTimeout = window.setTimeout(finalizeCollapse, cssDuration + HIDE_FALLBACK_EXTRA_DELAY);
|
|
33
57
|
return () => {
|
|
34
58
|
container?.removeEventListener('transitionend', onTransitionEnd);
|
|
59
|
+
window.clearTimeout(fallbackTimeout);
|
|
35
60
|
};
|
|
36
61
|
}, [collapsed, initialContentHeight]);
|
|
62
|
+
// render-phase state adjustments (not effects): the React Compiler lint forbids
|
|
63
|
+
// setState-in-effect, and https://react.dev/learn/you-might-not-need-an-effect
|
|
64
|
+
// documents this pattern for resetting state when props change
|
|
37
65
|
if (!collapsed && shouldHideContent) {
|
|
38
66
|
setShouldHideContent(false);
|
|
39
67
|
}
|
|
68
|
+
// without a transition there is no transitionend to wait for, so hide immediately
|
|
69
|
+
if (disableAnimation && !shouldHideContent && isFullyCollapsed(collapsed, initialContentHeight)) {
|
|
70
|
+
setShouldHideContent(true);
|
|
71
|
+
}
|
|
40
72
|
useEffect(() => {
|
|
41
73
|
if (contentRef.current) {
|
|
42
74
|
const observer = new ResizeObserver(() => {
|
|
@@ -52,10 +84,20 @@ export const CollapseContent = ({ children, minHeight = DEFAULT_HEIGHT, 'data-te
|
|
|
52
84
|
opacity: collapsed && !minHeight ? HIDDEN : VISIBLE,
|
|
53
85
|
};
|
|
54
86
|
const fadeShouldBeVisible = Boolean(minHeight && collapsed);
|
|
55
|
-
const
|
|
87
|
+
const contentVisible = !shouldHideContent;
|
|
88
|
+
const contentHidden = Boolean(keepMounted) && !contentVisible;
|
|
89
|
+
// interaction is blocked as soon as the panel collapses (matching aria-expanded), while
|
|
90
|
+
// visibility waits for the animation to finish so the content stays painted meanwhile.
|
|
91
|
+
// This includes a minHeight preview: content clipped below it must not take focus,
|
|
92
|
+
// and inert cannot be applied any more granularly than to the whole subtree
|
|
93
|
+
const contentInert = collapsed;
|
|
56
94
|
return (<div ref={containerRef} id={`collapse-content-${id}`} data-test={dataTests(COLLAPSE_CONTENT_CONTAINER_TEST_ID)} className={classNames(styles.container, { [styles.transition]: !disableAnimation })} style={style}>
|
|
57
|
-
<div ref={contentRef} data-test={dataTests(COLLAPSE_CONTENT_TEST_ID, dataTest)}
|
|
58
|
-
|
|
95
|
+
<div ref={contentRef} data-test={dataTests(COLLAPSE_CONTENT_TEST_ID, dataTest)}
|
|
96
|
+
// visibility: hidden removes the fully collapsed content from the tab order and accessibility
|
|
97
|
+
// tree, but descendants can override it with visibility: visible — inert cannot be escaped.
|
|
98
|
+
// Both are rendered in JSX so server-rendered collapsed markup is protected before hydration.
|
|
99
|
+
style={contentHidden ? { visibility: 'hidden' } : undefined} inert={contentInert ? INERT : undefined}>
|
|
100
|
+
{keepMounted || contentVisible ? children : null}
|
|
59
101
|
</div>
|
|
60
102
|
{fadeShouldBeVisible && <div className={styles.fade}/>}
|
|
61
103
|
</div>);
|
|
@@ -4,6 +4,15 @@ interface Props {
|
|
|
4
4
|
onChange?: (collapsed: boolean) => void;
|
|
5
5
|
duration?: number;
|
|
6
6
|
disableAnimation?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Keep children mounted while collapsed (hidden via `visibility: hidden`
|
|
9
|
+
* and the `inert` attribute) instead of unmounting them, preserving their state.
|
|
10
|
+
* Note: hidden form controls still participate in form validation
|
|
11
|
+
* and submission — disable them while collapsed if needed.
|
|
12
|
+
* Content rendered through portals (e.g. Popup) escapes the hidden
|
|
13
|
+
* wrapper and is not hidden — close overlays on collapse.
|
|
14
|
+
*/
|
|
15
|
+
keepMounted?: boolean;
|
|
7
16
|
className?: string;
|
|
8
17
|
defaultCollapsed?: boolean;
|
|
9
18
|
collapsed?: boolean | null;
|
|
@@ -5,7 +5,7 @@ import { BASE_ANIMATION_DURATION } from './consts';
|
|
|
5
5
|
/**
|
|
6
6
|
* @name Collapse
|
|
7
7
|
*/
|
|
8
|
-
export const Collapse = ({ children, duration = BASE_ANIMATION_DURATION, disableAnimation = false, className = '', onChange = () => { }, defaultCollapsed = true, collapsed = null, }) => {
|
|
8
|
+
export const Collapse = ({ children, duration = BASE_ANIMATION_DURATION, disableAnimation = false, keepMounted = false, className = '', onChange = () => { }, defaultCollapsed = true, collapsed = null, }) => {
|
|
9
9
|
const [innerCollapsed, setInnerCollapsed] = useState(defaultCollapsed);
|
|
10
10
|
const id = useId();
|
|
11
11
|
const finalCollapsedValue = collapsed ?? innerCollapsed;
|
|
@@ -19,6 +19,7 @@ export const Collapse = ({ children, duration = BASE_ANIMATION_DURATION, disable
|
|
|
19
19
|
setCollapsed,
|
|
20
20
|
duration,
|
|
21
21
|
disableAnimation,
|
|
22
|
+
keepMounted,
|
|
22
23
|
id,
|
|
23
24
|
}}>
|
|
24
25
|
{children}
|
|
@@ -10,6 +10,19 @@ export interface CollapsibleGroupProps {
|
|
|
10
10
|
onChange?: (expanded: boolean) => void;
|
|
11
11
|
disableAnimation?: boolean;
|
|
12
12
|
interactive?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Keep children mounted while collapsed (hidden via `visibility: hidden`
|
|
15
|
+
* and the `inert` attribute) instead of unmounting them, preserving their state.
|
|
16
|
+
* Note: hidden form controls still participate in form validation
|
|
17
|
+
* and submission — disable them while collapsed if needed.
|
|
18
|
+
* Content rendered through portals (e.g. Popup) escapes the hidden
|
|
19
|
+
* wrapper and is not hidden — close overlays on collapse.
|
|
20
|
+
*/
|
|
21
|
+
keepMounted?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Wraps the header in an <h2>–<h6> to keep the document outline.
|
|
24
|
+
*/
|
|
25
|
+
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
13
26
|
'data-test'?: string | null | undefined;
|
|
14
27
|
}
|
|
15
28
|
declare const CollapsibleGroup: React.ForwardRefExoticComponent<CollapsibleGroupProps & React.RefAttributes<HTMLDivElement>>;
|
|
@@ -36,7 +36,7 @@ function CollapsibleGroupHeaderStatic({ avatar, titleContent, subtitle }) {
|
|
|
36
36
|
<CollapsibleGroupHeaderContent avatar={avatar} titleContent={titleContent} subtitle={subtitle}/>
|
|
37
37
|
</span>);
|
|
38
38
|
}
|
|
39
|
-
const CollapsibleGroup = forwardRef(({ avatar, title, subtitle, children, className, defaultExpanded = false, expanded = null, onChange = () => { }, disableAnimation = false, interactive = true, 'data-test': dataTest, }, ref) => {
|
|
39
|
+
const CollapsibleGroup = forwardRef(({ avatar, title, subtitle, children, className, defaultExpanded = false, expanded = null, onChange = () => { }, disableAnimation = false, interactive = true, keepMounted = false, headingLevel, 'data-test': dataTest, }, ref) => {
|
|
40
40
|
const [innerExpanded, setInnerExpanded] = useState(defaultExpanded);
|
|
41
41
|
const [hovered, setHovered] = useState(false);
|
|
42
42
|
const [focused, setFocused] = useState(false);
|
|
@@ -60,9 +60,10 @@ const CollapsibleGroup = forwardRef(({ avatar, title, subtitle, children, classN
|
|
|
60
60
|
[styles.expanded]: isExpanded,
|
|
61
61
|
[styles.focused]: focused,
|
|
62
62
|
});
|
|
63
|
+
const header = interactive ? (<CollapsibleGroupHeader avatar={avatar} titleContent={title} subtitle={subtitle} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={() => setFocused(true)} onBlur={onBlur}/>) : (<CollapsibleGroupHeaderStatic avatar={avatar} titleContent={title} subtitle={subtitle}/>);
|
|
63
64
|
return (<div ref={ref} className={classes} data-test={dataTest}>
|
|
64
|
-
<Collapse defaultCollapsed={!defaultExpanded} collapsed={expanded == null ? null : !expanded} onChange={handleChange} disableAnimation={disableAnimation} className={styles.collapseRoot}>
|
|
65
|
-
{
|
|
65
|
+
<Collapse defaultCollapsed={!defaultExpanded} collapsed={expanded == null ? null : !expanded} onChange={handleChange} disableAnimation={disableAnimation} keepMounted={keepMounted} className={styles.collapseRoot}>
|
|
66
|
+
{headingLevel != null ? React.createElement(`h${headingLevel}`, { className: styles.heading }, header) : header}
|
|
66
67
|
<CollapseContent>
|
|
67
68
|
<div className={styles.body}>{children}</div>
|
|
68
69
|
</CollapseContent>
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
composes: header from '../collapsible-group/collapsible-group.css';
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
.heading {
|
|
31
|
+
composes: heading from '../collapsible-group/collapsible-group.css';
|
|
32
|
+
}
|
|
33
|
+
|
|
30
34
|
.headerButton {
|
|
31
35
|
composes: headerButton from '../collapsible-group/collapsible-group.css';
|
|
32
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jetbrains/ring-ui",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.123-beta.0",
|
|
4
4
|
"description": "JetBrains UI library",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "JetBrains"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"babel.config.js",
|
|
12
12
|
"components",
|
|
13
13
|
"postcss.config.js",
|
|
14
|
+
"skills",
|
|
14
15
|
"webpack.config.js",
|
|
15
16
|
"typings.d.ts"
|
|
16
17
|
],
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
"prebuild": "rimraf components && npm run prebuild:tsc && cpy './**/*' '!**/*.stories.*' '!**/*.figma.*' '!**/*.ts' '!**/*.tsx' '!**/test-helpers/mocks/**' ../components --parents --cwd=src/",
|
|
69
70
|
"prebuild:tsc": "(npm run type-check:create-d-ts && tsc --project tsconfig-build.json) ; npm run type-check:cleanup-d-ts",
|
|
70
71
|
"prepare": "webpack -c .storybook/custom-header/webpack.config.js",
|
|
71
|
-
"prepublishOnly": "pinst --disable",
|
|
72
|
+
"prepublishOnly": "npm run verify-package-skills && pinst --disable",
|
|
72
73
|
"prerelease-built-ci": "node scripts/prepare-built-package.js",
|
|
73
74
|
"prerelease-ci": "git pull",
|
|
74
75
|
"release-built-ci": "npm publish --access=public $NPM_PUBLISH_PARAMS",
|
|
@@ -83,6 +84,7 @@
|
|
|
83
84
|
"stylelint": "stylelint --ignore-path .stylelintignore '**/*.css'",
|
|
84
85
|
"stylelint-ci": "stylelint --ignore-path .stylelintignore --custom-formatter 'scripts/jslint-xml.js' '**/*.css' | xmlappend eslint-report.xml",
|
|
85
86
|
"test": "vitest src",
|
|
87
|
+
"verify-package-skills": "node scripts/verify-package-skills.mjs",
|
|
86
88
|
"type-check": "(npm run type-check:create-d-ts && npm run type-check:main && npm run type-check:build) ; npm run type-check:cleanup-d-ts",
|
|
87
89
|
"type-check:create-d-ts": "npx tcm src && npx tcm .storybook",
|
|
88
90
|
"type-check:main": "tsc --noEmit -p tsconfig.json",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ring-ui
|
|
3
|
+
description: Use when implementing, laying out, theming, validating, or troubleshooting React interfaces that use @jetbrains/ring-ui-built.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Ring UI
|
|
7
|
+
|
|
8
|
+
Use Ring UI for controls, surfaces, and design tokens. Let semantic HTML and native CSS Flexbox/Grid own page structure and geometry.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
Use this skill for React pages, settings forms, dialogs, navigation, responsive composition, themes, loading/empty/error states, or accessibility work in a project that uses or is being set up to use `@jetbrains/ring-ui-built`.
|
|
13
|
+
|
|
14
|
+
Do not use it for non-React interfaces, projects that neither use nor are being set up to use Ring UI, or product-specific YouTrack infrastructure. Do not introduce Ring UI merely to solve generic page layout.
|
|
15
|
+
|
|
16
|
+
## Workflow
|
|
17
|
+
|
|
18
|
+
1. Before initializing or laying out an application, read [references/page-layouts.md](references/page-layouts.md) and follow its app setup, even if `@jetbrains/ring-ui-built` is not installed yet.
|
|
19
|
+
2. If the package is installed, inspect `package.json` and the lockfile for its exact version. Read its `README.md` and relevant `.d.ts` files before choosing imports or props.
|
|
20
|
+
3. Import components only from `@jetbrains/ring-ui-built`. Compose Ring controls and surfaces inside semantic landmarks, and use native CSS Flexbox/Grid for page geometry and CSS media/container queries for responsiveness.
|
|
21
|
+
4. Use `--ring-*` tokens for colors, typography, borders, and spacing based on `--ring-unit`. Do not copy their resolved values.
|
|
22
|
+
5. Model form draft, saved, validation, submission, and result state explicitly. Distinguish blocking loading, local loading, background work, first-use empty, filtered empty, success, warning, and retryable failure.
|
|
23
|
+
|
|
24
|
+
## Preferred patterns
|
|
25
|
+
|
|
26
|
+
- Use one semantic `h1`, clear landmarks, native anchors for navigation, and native/Ring buttons for actions. Do not make clickable `div` elements.
|
|
27
|
+
- Prefer `Input`'s own `label`. Give externally labelled controls stable IDs and connect `htmlFor`, `aria-describedby`, and errors explicitly.
|
|
28
|
+
- Keep `Select` controlled and explicitly generic: use `Select<T>` with `SelectItem<T>[]` data, selected item, and `onChange` argument—never `Select<SelectItem<T>>` or guessed primitive values.
|
|
29
|
+
- Wrap error `Banner` content in `role="alert"`, non-urgent status/success content in `role="status"`, blocking `LoaderScreen` content in a named status region, and local `LoaderInline` content in a named status region. Avoid announcing background work that does not affect the user.
|
|
30
|
+
- Use CSS Modules. Put `min-width: 0` on shrinking grid/flex children, `min-height: 0` where vertical flex children scroll, and assign each axis to one explicit scroll owner.
|
|
31
|
+
- Use CSS media queries for page-wide geometry and container queries for reusable components. Branch in React only when interaction or content structure must change.
|
|
32
|
+
- Never style Ring UI internal classes or `data-test` attributes. Treat test hooks as behavior selectors, not CSS APIs.
|
|
33
|
+
|
|
34
|
+
Do not add new uses of Ring UI's deprecated `Grid`/`Row`/`Col` or `ContentLayout`. Do not copy product-specific YouTrack wrappers such as `ButtonLegacy` or `IconLegacy`, page shells, portals, sticky stacks, form wrappers, or alert services into package consumers.
|
|
35
|
+
|
|
36
|
+
## Common tasks
|
|
37
|
+
|
|
38
|
+
- App setup, page shells, responsive navigation, sticky regions, and scroll ownership: read [references/page-layouts.md](references/page-layouts.md).
|
|
39
|
+
- Forms, validation, dirty state, async saving, and page feedback: read [references/forms-and-feedback.md](references/forms-and-feedback.md).
|
|
40
|
+
|
|
41
|
+
## Validation
|
|
42
|
+
|
|
43
|
+
Run the consuming repository's type-check, focused tests, lint, and production build equivalents. Interactively check near `1280x800` and `390x844`: accessible names and keyboard order, every state transition and action, page and popup overflow, heading hierarchy, theme behavior, and the browser console.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Forms and feedback
|
|
2
|
+
|
|
3
|
+
Use a native `<form>` as the composition root. Ring UI supplies controls and feedback surfaces; React owns the saved snapshot, draft, validation, submission, and result states.
|
|
4
|
+
|
|
5
|
+
The example and consumer imports use `@jetbrains/ring-ui-built`.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [Complete controlled settings form](#complete-controlled-settings-form)
|
|
10
|
+
- [Form-state rules](#form-state-rules)
|
|
11
|
+
- [Page-state decisions](#page-state-decisions)
|
|
12
|
+
|
|
13
|
+
## Complete controlled settings form
|
|
14
|
+
|
|
15
|
+
`project-settings-form.tsx`:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import {useState, type FormEvent} from 'react';
|
|
19
|
+
import Banner from '@jetbrains/ring-ui-built/components/banner/banner';
|
|
20
|
+
import Button from '@jetbrains/ring-ui-built/components/button/button';
|
|
21
|
+
import ButtonSet from '@jetbrains/ring-ui-built/components/button-set/button-set';
|
|
22
|
+
import ControlLabel from '@jetbrains/ring-ui-built/components/control-label/control-label';
|
|
23
|
+
import Input from '@jetbrains/ring-ui-built/components/input/input';
|
|
24
|
+
import Select, {type SelectItem} from '@jetbrains/ring-ui-built/components/select/select';
|
|
25
|
+
import Toggle from '@jetbrains/ring-ui-built/components/toggle/toggle';
|
|
26
|
+
|
|
27
|
+
import styles from './project-settings-form.module.css';
|
|
28
|
+
|
|
29
|
+
type Visibility = 'private' | 'team' | 'public';
|
|
30
|
+
|
|
31
|
+
type VisibilityData = {
|
|
32
|
+
value: Visibility;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Settings = {
|
|
36
|
+
name: string;
|
|
37
|
+
visibility: Visibility;
|
|
38
|
+
notifications: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type ProjectSettingsFormProps = {
|
|
42
|
+
initialValue: Settings;
|
|
43
|
+
onSave: (value: Settings) => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const VISIBILITY_OPTIONS: SelectItem<VisibilityData>[] = [
|
|
47
|
+
{key: 'private', label: 'Private', value: 'private'},
|
|
48
|
+
{key: 'team', label: 'Team', value: 'team'},
|
|
49
|
+
{key: 'public', label: 'Public', value: 'public'},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const NAME_ID = 'project-settings-name';
|
|
53
|
+
const NAME_HELP_ID = 'project-settings-name-help';
|
|
54
|
+
const NAME_ERROR_ID = 'project-settings-name-error';
|
|
55
|
+
const VISIBILITY_ID = 'project-settings-visibility';
|
|
56
|
+
const NOTIFICATIONS_ID = 'project-settings-notifications';
|
|
57
|
+
|
|
58
|
+
export function ProjectSettingsForm({initialValue, onSave}: ProjectSettingsFormProps) {
|
|
59
|
+
const [saved, setSaved] = useState(initialValue);
|
|
60
|
+
const [draft, setDraft] = useState(initialValue);
|
|
61
|
+
const [submitting, setSubmitting] = useState(false);
|
|
62
|
+
const [showErrors, setShowErrors] = useState(false);
|
|
63
|
+
const [savedMessage, setSavedMessage] = useState<string | null>(null);
|
|
64
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
65
|
+
|
|
66
|
+
const nameError = draft.name.trim() ? null : 'Project name is required.';
|
|
67
|
+
const dirty =
|
|
68
|
+
draft.name !== saved.name ||
|
|
69
|
+
draft.visibility !== saved.visibility ||
|
|
70
|
+
draft.notifications !== saved.notifications;
|
|
71
|
+
const selectedVisibility =
|
|
72
|
+
VISIBILITY_OPTIONS.find(option => option.value === draft.visibility) ?? null;
|
|
73
|
+
|
|
74
|
+
const updateDraft = (next: Settings) => {
|
|
75
|
+
setDraft(next);
|
|
76
|
+
setSavedMessage(null);
|
|
77
|
+
setSaveError(null);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
if (submitting) return;
|
|
83
|
+
|
|
84
|
+
setShowErrors(true);
|
|
85
|
+
setSavedMessage(null);
|
|
86
|
+
setSaveError(null);
|
|
87
|
+
if (nameError) return;
|
|
88
|
+
|
|
89
|
+
const next = {...draft, name: draft.name.trim()};
|
|
90
|
+
setSubmitting(true);
|
|
91
|
+
try {
|
|
92
|
+
await onSave(next);
|
|
93
|
+
setSaved(next);
|
|
94
|
+
setDraft(next);
|
|
95
|
+
setShowErrors(false);
|
|
96
|
+
setSavedMessage('Project settings saved.');
|
|
97
|
+
} catch {
|
|
98
|
+
setSaveError('Could not save project settings. Try again.');
|
|
99
|
+
} finally {
|
|
100
|
+
setSubmitting(false);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleCancel = () => {
|
|
105
|
+
setDraft(saved);
|
|
106
|
+
setShowErrors(false);
|
|
107
|
+
setSavedMessage(null);
|
|
108
|
+
setSaveError(null);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const nameDescribedBy =
|
|
112
|
+
showErrors && nameError ? `${NAME_HELP_ID} ${NAME_ERROR_ID}` : NAME_HELP_ID;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<form className={styles.form} onSubmit={handleSubmit} noValidate>
|
|
116
|
+
{saveError && (
|
|
117
|
+
<div role="alert">
|
|
118
|
+
<Banner mode="error" withIcon>
|
|
119
|
+
{saveError}
|
|
120
|
+
</Banner>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
{savedMessage && (
|
|
124
|
+
<div role="status">
|
|
125
|
+
<Banner mode="success" withIcon>
|
|
126
|
+
{savedMessage}
|
|
127
|
+
</Banner>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<div className={styles.field}>
|
|
132
|
+
<Input
|
|
133
|
+
id={NAME_ID}
|
|
134
|
+
label="Project name"
|
|
135
|
+
value={draft.name}
|
|
136
|
+
required
|
|
137
|
+
disabled={submitting}
|
|
138
|
+
aria-invalid={showErrors && Boolean(nameError)}
|
|
139
|
+
aria-describedby={nameDescribedBy}
|
|
140
|
+
error={showErrors && nameError ? '' : undefined}
|
|
141
|
+
onChange={event => updateDraft({...draft, name: event.currentTarget.value})}
|
|
142
|
+
/>
|
|
143
|
+
<p id={NAME_HELP_ID} className={styles.help}>
|
|
144
|
+
Shown in project navigation and search.
|
|
145
|
+
</p>
|
|
146
|
+
{showErrors && nameError && (
|
|
147
|
+
<p id={NAME_ERROR_ID} className={styles.error}>
|
|
148
|
+
{nameError}
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className={styles.field}>
|
|
154
|
+
<Select<VisibilityData>
|
|
155
|
+
id={VISIBILITY_ID}
|
|
156
|
+
data={VISIBILITY_OPTIONS}
|
|
157
|
+
selected={selectedVisibility}
|
|
158
|
+
selectedLabel="Visibility"
|
|
159
|
+
label="Choose visibility"
|
|
160
|
+
disabled={submitting}
|
|
161
|
+
onChange={(option: SelectItem<VisibilityData> | null) => {
|
|
162
|
+
if (option) updateDraft({...draft, visibility: option.value});
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
<p className={styles.help}>Controls who can discover and open this project.</p>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className={styles.toggleField}>
|
|
169
|
+
<ControlLabel htmlFor={NOTIFICATIONS_ID} disabled={submitting}>
|
|
170
|
+
Email notifications
|
|
171
|
+
</ControlLabel>
|
|
172
|
+
<Toggle
|
|
173
|
+
id={NOTIFICATIONS_ID}
|
|
174
|
+
checked={draft.notifications}
|
|
175
|
+
disabled={submitting}
|
|
176
|
+
onChange={event => updateDraft({...draft, notifications: event.currentTarget.checked})}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{dirty && (
|
|
181
|
+
<ButtonSet className={styles.actions}>
|
|
182
|
+
<Button primary type="submit" loader={submitting} disabled={submitting}>
|
|
183
|
+
Save
|
|
184
|
+
</Button>
|
|
185
|
+
<Button type="button" disabled={submitting} onClick={handleCancel}>
|
|
186
|
+
Cancel
|
|
187
|
+
</Button>
|
|
188
|
+
</ButtonSet>
|
|
189
|
+
)}
|
|
190
|
+
</form>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`project-settings-form.module.css`:
|
|
196
|
+
|
|
197
|
+
```css
|
|
198
|
+
.form {
|
|
199
|
+
display: grid;
|
|
200
|
+
width: min(100%, calc(var(--ring-unit) * 75));
|
|
201
|
+
gap: calc(var(--ring-unit) * 3);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.field {
|
|
205
|
+
display: grid;
|
|
206
|
+
gap: calc(var(--ring-unit) / 2);
|
|
207
|
+
min-width: 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.help,
|
|
211
|
+
.error {
|
|
212
|
+
margin: 0;
|
|
213
|
+
font-size: var(--ring-font-size-smaller);
|
|
214
|
+
line-height: var(--ring-line-height-lowest);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.help {
|
|
218
|
+
color: var(--ring-secondary-color);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.error {
|
|
222
|
+
color: var(--ring-error-color);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.toggleField {
|
|
226
|
+
display: flex;
|
|
227
|
+
align-items: center;
|
|
228
|
+
justify-content: space-between;
|
|
229
|
+
gap: calc(var(--ring-unit) * 2);
|
|
230
|
+
padding-block: var(--ring-unit);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.actions {
|
|
234
|
+
padding-block-start: calc(var(--ring-unit) * 2);
|
|
235
|
+
border-block-start: 1px solid var(--ring-line-color);
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Form-state rules
|
|
240
|
+
|
|
241
|
+
- Keep persisted `saved` data separate from editable `draft`. Derive validity, dirty state, and the selected `SelectItem` instead of storing duplicates.
|
|
242
|
+
- React only uses `initialValue` for the first `useState` call. If the mounted form can switch records or receive a server refresh, resynchronize `saved` and `draft` from a stable record ID/version. Decide explicitly whether a dirty draft wins, is discarded, or requires confirmation; do not reset on every new object reference.
|
|
243
|
+
- Add an unsaved-navigation guard when leaving the route, closing a sidebar, or dismissing a dialog could destroy a dirty draft. Remove the guard after save or cancel.
|
|
244
|
+
- Keep field rules close to their derived errors. Reveal errors on submit or blur, retain persistent help, connect all help/error IDs with `aria-describedby`, and move focus to the first invalid field when long forms can fail off-screen.
|
|
245
|
+
- Prevent duplicate submission in both the handler and controls. While saving, propagate `disabled` to every editable control, external label/help action, Save, Cancel, and dependent section; use the primary button's `loader` for local progress.
|
|
246
|
+
- Put actions after page fields for ordinary pages, in the owning sidebar's sticky/footer region for sidebar editors, and in the dialog action area for dialogs. Keep them inside the native form so Enter submits predictably.
|
|
247
|
+
|
|
248
|
+
## Page-state decisions
|
|
249
|
+
|
|
250
|
+
| State | Use | Accessibility and behavior |
|
|
251
|
+
| --- | --- | --- |
|
|
252
|
+
| Blocking loading | `LoaderScreen` in the main region | Give the region `role="status"` and an accessible loading name; replace it with content when ready. |
|
|
253
|
+
| Local loading | `LoaderInline` beside the affected section/control | Name the local status; keep unrelated content and actions usable. |
|
|
254
|
+
| Background loading | Subtle inline progress only when users need it | Do not block the page or repeatedly announce silent refreshes. |
|
|
255
|
+
| Initial empty | Purpose, short explanation, and primary creation/setup action | Distinguish absence of data from an error. |
|
|
256
|
+
| Filtered empty | “No matches” plus clear/change-filter action | Preserve the user's data and current filter context. |
|
|
257
|
+
| Contextual risk | Persistent warning/error `Banner` near the affected controls | Use `role="alert"` only when immediate interruption is warranted. |
|
|
258
|
+
| Success | `Banner` or message in `role="status"` near the saved region | Confirm the completed action without moving focus. |
|
|
259
|
+
| Retryable failure | Error `Banner` in `role="alert"` with a retry path | Preserve the draft, stop the loader, and re-enable controls. |
|
|
260
|
+
|
|
261
|
+
Avoid uncontrolled forms that also maintain a shared saved snapshot, untyped `Select` values or guessed primitive selections, duplicate submission paths, inaccessible labels/help, conflating first-use and filtered empty states, and transient alerts as a replacement for persistent field validation.
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Page layouts
|
|
2
|
+
|
|
3
|
+
Ring UI supplies controls, surfaces, and tokens. It does not replace page geometry: semantic HTML defines the document, while native CSS Grid/Flexbox defines the shell, widths, responsive behavior, sticky regions, and scroll ownership.
|
|
4
|
+
|
|
5
|
+
The examples and consumer imports use `@jetbrains/ring-ui-built`.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [App setup](#app-setup)
|
|
10
|
+
- [Complete page shell](#complete-page-shell)
|
|
11
|
+
- [Geometry rules](#geometry-rules)
|
|
12
|
+
- [Sticky offsets and nested scopes](#sticky-offsets-and-nested-scopes)
|
|
13
|
+
- [Theme, semantics, and accessibility](#theme-semantics-and-accessibility)
|
|
14
|
+
|
|
15
|
+
## App setup
|
|
16
|
+
|
|
17
|
+
Use the repository's package manager to install the built package. For example, with npm:
|
|
18
|
+
|
|
19
|
+
```shell
|
|
20
|
+
npm install @jetbrains/ring-ui-built
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Import the consolidated stylesheet exactly once at the application entry point. Import components from the same package, and put `ThemeProvider` near the application root with `Theme.AUTO` and `passToPopups`:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import {createRoot} from 'react-dom/client';
|
|
27
|
+
import '@jetbrains/ring-ui-built/components/style.css';
|
|
28
|
+
import Theme, {ThemeProvider} from '@jetbrains/ring-ui-built/components/global/theme';
|
|
29
|
+
|
|
30
|
+
import App from './App';
|
|
31
|
+
|
|
32
|
+
const rootElement = document.getElementById('root');
|
|
33
|
+
if (!rootElement) {
|
|
34
|
+
throw new Error('Root element #root was not found.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const root = createRoot(rootElement);
|
|
38
|
+
|
|
39
|
+
root.render(
|
|
40
|
+
<ThemeProvider theme={Theme.AUTO} passToPopups target={document.body}>
|
|
41
|
+
<App />
|
|
42
|
+
</ThemeProvider>,
|
|
43
|
+
);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Add `class="plugin"` to `<body>`:
|
|
47
|
+
|
|
48
|
+
```html
|
|
49
|
+
<body class="plugin">
|
|
50
|
+
<div id="root"></div>
|
|
51
|
+
</body>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Add the baseline page styles to application CSS:
|
|
55
|
+
|
|
56
|
+
```css
|
|
57
|
+
body.plugin {
|
|
58
|
+
padding: 0 16px;
|
|
59
|
+
background-color: var(--ring-content-background-color);
|
|
60
|
+
color: var(--ring-text-color);
|
|
61
|
+
font-size: var(--ring-font-size);
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A deliberately full-bleed shell can override the body padding in application CSS.
|
|
66
|
+
|
|
67
|
+
## Complete page shell
|
|
68
|
+
|
|
69
|
+
`page-shell.tsx`:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import type {ReactNode} from 'react';
|
|
73
|
+
import Button from '@jetbrains/ring-ui-built/components/button/button';
|
|
74
|
+
import {H1} from '@jetbrains/ring-ui-built/components/heading/heading';
|
|
75
|
+
|
|
76
|
+
import styles from './page-shell.module.css';
|
|
77
|
+
|
|
78
|
+
type PageShellProps = {
|
|
79
|
+
children: ReactNode;
|
|
80
|
+
currentPath: string;
|
|
81
|
+
onCreateProject: () => void;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const primaryNavigation = [
|
|
85
|
+
{href: '/projects', label: 'Projects'},
|
|
86
|
+
{href: '/teams', label: 'Teams'},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const settingsNavigation = [
|
|
90
|
+
{href: '/projects/settings/general', label: 'General'},
|
|
91
|
+
{href: '/projects/settings/access', label: 'Access'},
|
|
92
|
+
{href: '/projects/settings/integrations', label: 'Integrations'},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
export function PageShell({children, currentPath, onCreateProject}: PageShellProps) {
|
|
96
|
+
return (
|
|
97
|
+
<div className={styles.shell}>
|
|
98
|
+
<a className={styles.skipLink} href="#main-content">
|
|
99
|
+
Skip to content
|
|
100
|
+
</a>
|
|
101
|
+
|
|
102
|
+
<header className={styles.topBar}>
|
|
103
|
+
<a className={styles.brand} href="/" aria-label="Acme home">
|
|
104
|
+
Acme
|
|
105
|
+
</a>
|
|
106
|
+
<nav className={styles.topNavigation} aria-label="Primary">
|
|
107
|
+
{primaryNavigation.map(item => (
|
|
108
|
+
<a
|
|
109
|
+
key={item.href}
|
|
110
|
+
className={styles.navigationLink}
|
|
111
|
+
href={item.href}
|
|
112
|
+
aria-current={currentPath === item.href ? 'page' : undefined}
|
|
113
|
+
>
|
|
114
|
+
{item.label}
|
|
115
|
+
</a>
|
|
116
|
+
))}
|
|
117
|
+
</nav>
|
|
118
|
+
<Button primary onClick={onCreateProject}>
|
|
119
|
+
New project
|
|
120
|
+
</Button>
|
|
121
|
+
</header>
|
|
122
|
+
|
|
123
|
+
<div className={styles.workspace}>
|
|
124
|
+
<aside className={styles.sidebar}>
|
|
125
|
+
<nav className={styles.sidebarNavigation} aria-label="Project settings">
|
|
126
|
+
{settingsNavigation.map(item => (
|
|
127
|
+
<a
|
|
128
|
+
key={item.href}
|
|
129
|
+
className={styles.sidebarLink}
|
|
130
|
+
href={item.href}
|
|
131
|
+
aria-current={currentPath === item.href ? 'page' : undefined}
|
|
132
|
+
>
|
|
133
|
+
{item.label}
|
|
134
|
+
</a>
|
|
135
|
+
))}
|
|
136
|
+
</nav>
|
|
137
|
+
</aside>
|
|
138
|
+
|
|
139
|
+
<main id="main-content" className={styles.main} tabIndex={-1}>
|
|
140
|
+
<div className={styles.content}>
|
|
141
|
+
<H1>Project settings</H1>
|
|
142
|
+
{children}
|
|
143
|
+
</div>
|
|
144
|
+
</main>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`page-shell.module.css`:
|
|
152
|
+
|
|
153
|
+
```css
|
|
154
|
+
.shell {
|
|
155
|
+
--page-top-offset: calc(var(--ring-unit) * 8);
|
|
156
|
+
|
|
157
|
+
min-height: 100dvh;
|
|
158
|
+
color: var(--ring-text-color);
|
|
159
|
+
background: var(--ring-content-background-color);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.skipLink {
|
|
163
|
+
position: absolute;
|
|
164
|
+
z-index: 20;
|
|
165
|
+
inset-block-start: var(--ring-unit);
|
|
166
|
+
inset-inline-start: var(--ring-unit);
|
|
167
|
+
padding: var(--ring-unit) calc(var(--ring-unit) * 2);
|
|
168
|
+
color: var(--ring-link-color);
|
|
169
|
+
background: var(--ring-content-background-color);
|
|
170
|
+
border: 1px solid var(--ring-borders-color);
|
|
171
|
+
transform: translateY(-200%);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.skipLink:focus {
|
|
175
|
+
transform: none;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.topBar {
|
|
179
|
+
position: sticky;
|
|
180
|
+
z-index: 10;
|
|
181
|
+
inset-block-start: 0;
|
|
182
|
+
display: flex;
|
|
183
|
+
min-height: var(--page-top-offset);
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: calc(var(--ring-unit) * 2);
|
|
186
|
+
padding-inline: calc(var(--ring-unit) * 4);
|
|
187
|
+
background: var(--ring-navigation-background-color);
|
|
188
|
+
border-block-end: 1px solid var(--ring-line-color);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.brand {
|
|
192
|
+
flex: none;
|
|
193
|
+
color: var(--ring-text-color);
|
|
194
|
+
font-weight: bold;
|
|
195
|
+
text-decoration: none;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.topNavigation {
|
|
199
|
+
display: flex;
|
|
200
|
+
flex: 1;
|
|
201
|
+
flex-wrap: wrap;
|
|
202
|
+
gap: var(--ring-unit) calc(var(--ring-unit) * 2);
|
|
203
|
+
min-width: 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.navigationLink,
|
|
207
|
+
.sidebarLink {
|
|
208
|
+
color: var(--ring-link-color);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.navigationLink[aria-current='page'],
|
|
212
|
+
.sidebarLink[aria-current='page'] {
|
|
213
|
+
color: var(--ring-text-color);
|
|
214
|
+
font-weight: bold;
|
|
215
|
+
text-decoration: none;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.workspace {
|
|
219
|
+
display: grid;
|
|
220
|
+
grid-template-columns: calc(var(--ring-unit) * 30) minmax(0, 1fr);
|
|
221
|
+
min-height: calc(100dvh - var(--page-top-offset));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.sidebar {
|
|
225
|
+
position: sticky;
|
|
226
|
+
inset-block-start: var(--page-top-offset);
|
|
227
|
+
align-self: start;
|
|
228
|
+
padding: calc(var(--ring-unit) * 3);
|
|
229
|
+
background: var(--ring-sidebar-background-color);
|
|
230
|
+
border-inline-end: 1px solid var(--ring-line-color);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.sidebarNavigation {
|
|
234
|
+
display: flex;
|
|
235
|
+
flex-direction: column;
|
|
236
|
+
gap: var(--ring-unit);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.main {
|
|
240
|
+
min-width: 0;
|
|
241
|
+
padding: calc(var(--ring-unit) * 4);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.content {
|
|
245
|
+
width: min(100%, calc(var(--ring-unit) * 120));
|
|
246
|
+
margin-inline: auto;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@media (max-width: 760px) {
|
|
250
|
+
.shell {
|
|
251
|
+
--page-top-offset: 0px;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.topBar {
|
|
255
|
+
position: static;
|
|
256
|
+
flex-wrap: wrap;
|
|
257
|
+
padding: calc(var(--ring-unit) * 2);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.topNavigation {
|
|
261
|
+
order: 3;
|
|
262
|
+
flex-basis: 100%;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.workspace {
|
|
266
|
+
grid-template-columns: minmax(0, 1fr);
|
|
267
|
+
min-height: 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.sidebar {
|
|
271
|
+
position: static;
|
|
272
|
+
min-width: 0;
|
|
273
|
+
padding: var(--ring-unit) calc(var(--ring-unit) * 2);
|
|
274
|
+
overflow-x: auto;
|
|
275
|
+
border-inline-end: 0;
|
|
276
|
+
border-block-end: 1px solid var(--ring-line-color);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.sidebarNavigation {
|
|
280
|
+
width: max-content;
|
|
281
|
+
flex-direction: row;
|
|
282
|
+
gap: calc(var(--ring-unit) * 2);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.main {
|
|
286
|
+
padding: calc(var(--ring-unit) * 2);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Geometry rules
|
|
292
|
+
|
|
293
|
+
- Use `--ring-unit` as the spacing base and semantic tokens such as `--ring-content-background-color`, `--ring-sidebar-background-color`, `--ring-text-color`, and `--ring-line-color`. Do not paste their current pixel or color values into application CSS.
|
|
294
|
+
- In Grid/Flexbox, `min-width: auto` can stop content from shrinking and `min-height: auto` can stop vertical children from scrolling. Put `min-width: 0` or `min-height: 0` on the child that must shrink.
|
|
295
|
+
- Assign exactly one scroll owner per axis and scope. Prefer document scrolling for a page; use a named inner owner only for a deliberate viewport-like region. The mobile sidebar above owns only its horizontal overflow. A dialog or portal is a separate scroll scope.
|
|
296
|
+
- Bound long-form reading content rather than stretching it across the viewport. Choose a product-appropriate token-multiple maximum; allow tables, canvases, and dense dashboards to use a wider explicit region.
|
|
297
|
+
- Use CSS media queries when only geometry changes. Branch in React when narrow layouts need different interaction, focus order, or content—not to reproduce `display`, wrapping, or column changes.
|
|
298
|
+
- Use container queries for reusable widgets whose layout depends on their allocated panel width. Give the wrapper `container-type: inline-size` and keep viewport queries for the outer application shell.
|
|
299
|
+
|
|
300
|
+
## Sticky offsets and nested scopes
|
|
301
|
+
|
|
302
|
+
Prefer a simple inherited CSS variable when sticky heights are known:
|
|
303
|
+
|
|
304
|
+
```css
|
|
305
|
+
.page {
|
|
306
|
+
--sticky-offset: calc(var(--ring-unit) * 8);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.localToolbar {
|
|
310
|
+
position: sticky;
|
|
311
|
+
inset-block-start: var(--sticky-offset);
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Measure in JavaScript only when the preceding sticky height is genuinely dynamic; publish the measured value as a CSS variable instead of calculating every child position in React. Keep portal and dialog scroll containers isolated: their sticky offsets begin at that scope's top, not the document header.
|
|
316
|
+
|
|
317
|
+
## Theme, semantics, and accessibility
|
|
318
|
+
|
|
319
|
+
- Put `ThemeProvider` near the application root and use `Theme.AUTO`; use `passToPopups` when popup content must inherit the theme. Keep token-based application CSS inside the same themed subtree.
|
|
320
|
+
- Keep one real `h1` in `main`. Use `header`, labelled `nav`, `aside`, and `main` landmarks; do not use visual heading styles as a substitute for heading order.
|
|
321
|
+
- Use native anchors for navigation so open-in-new-tab, copy-link, and browser history continue to work. Use buttons for actions.
|
|
322
|
+
- Preserve visible focus, keyboard order, skip navigation, accessible names, and `aria-current="page"`. Ensure sticky content does not obscure focused targets or anchor destinations; use `scroll-margin-block-start` where needed.
|
|
323
|
+
|
|
324
|
+
Avoid deprecated `Grid`/`Row`/`Col` and `ContentLayout`, hardcoded copies of token values, fixed positioning where sticky works, JavaScript viewport checks for pure layout, styling Ring internals or `data-test`, and product-specific frameworks or portal conventions.
|