@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 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
- function onTransitionEnd() {
36
+ const container = containerRef.current;
37
+ function finalizeCollapse() {
27
38
  if (initialContentHeight <= DEFAULT_HEIGHT) {
28
39
  setShouldHideContent(collapsed);
29
40
  }
30
41
  }
31
- const container = containerRef.current;
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 shouldRenderContent = disableAnimation ? !collapsed : !shouldHideContent;
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
- {shouldRenderContent ? children : null}
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>);
@@ -2,6 +2,7 @@ interface CollapseContextInterface {
2
2
  collapsed: boolean;
3
3
  duration: number;
4
4
  disableAnimation: boolean;
5
+ keepMounted?: boolean;
5
6
  setCollapsed: () => void;
6
7
  id: string;
7
8
  }
@@ -4,6 +4,7 @@ export const CollapseContext = createContext({
4
4
  collapsed: true,
5
5
  duration: BASE_ANIMATION_DURATION,
6
6
  disableAnimation: false,
7
+ keepMounted: false,
7
8
  setCollapsed: () => { },
8
9
  id: '',
9
10
  });
@@ -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}
@@ -37,6 +37,12 @@
37
37
  height: 28px;
38
38
  }
39
39
 
40
+ .heading {
41
+ margin: 0;
42
+
43
+ font: inherit;
44
+ }
45
+
40
46
  .headerButton {
41
47
  display: block;
42
48
 
@@ -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
- {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}/>)}
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.121",
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.