From 9a1de8f3ddd823c7b55bcad4eca3459474855d7a Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Mon, 3 Nov 2025 18:32:15 +0100 Subject: [PATCH 01/19] major: env locking --- packages/box/src/index.ts | 24 ++- .../environment/examples/lock-no-override.tsx | 77 +++++++++ .../examples/lock-with-override.tsx | 16 ++ packages/environment/src/index.tsx | 34 +++- .../test/lock-generation.browser.test.tsx | 94 +++++++++++ packages/internal-props/CHANGELOG.md | 7 - packages/internal-props/LICENSE | 9 - packages/internal-props/README.mdx | 69 -------- .../examples/override-classname.tsx | 49 ------ .../internal-props/internal-props.stories.tsx | 8 - packages/internal-props/package.json | 63 ------- packages/internal-props/src/index.ts | 80 --------- .../test/examples.browser.test.tsx | 30 ---- .../internal-props/test/internal.node.test.ts | 158 ------------------ packages/internal-props/tsconfig.json | 8 - packages/internal-props/tsup.config.ts | 33 ---- packages/internal-props/vitest.config.ts | 11 -- packages/slots/src/override.ts | 43 ++++- packages/slots/src/slots.tsx | 26 +++ packages/slots/test/override.node.test.ts | 114 +++++++++++++ packages/slots/test/slots.node.test.ts | 63 +++++++ packages/use-props/src/index.ts | 9 +- .../use-props/test/use-props.node.test.tsx | 19 +++ 23 files changed, 505 insertions(+), 539 deletions(-) create mode 100644 packages/environment/examples/lock-no-override.tsx create mode 100644 packages/environment/examples/lock-with-override.tsx create mode 100644 packages/environment/test/lock-generation.browser.test.tsx delete mode 100644 packages/internal-props/CHANGELOG.md delete mode 100644 packages/internal-props/LICENSE delete mode 100644 packages/internal-props/README.mdx delete mode 100644 packages/internal-props/examples/override-classname.tsx delete mode 100644 packages/internal-props/internal-props.stories.tsx delete mode 100644 packages/internal-props/package.json delete mode 100644 packages/internal-props/src/index.ts delete mode 100644 packages/internal-props/test/examples.browser.test.tsx delete mode 100644 packages/internal-props/test/internal.node.test.ts delete mode 100644 packages/internal-props/tsconfig.json delete mode 100644 packages/internal-props/tsup.config.ts delete mode 100644 packages/internal-props/vitest.config.ts diff --git a/packages/box/src/index.ts b/packages/box/src/index.ts index 08754ff5d..081f1ce9d 100644 --- a/packages/box/src/index.ts +++ b/packages/box/src/index.ts @@ -26,6 +26,19 @@ export interface EnvContext { */ sprite: string; + /** + * Indicates if the environment is currently locked. + * When locked, modifications applied after the lock are flagged with data-override. + */ + locked: boolean; + + /** + * Current lock generation number. + * Increments each time a new Environment with lock={true} is created. + * Used to determine if a slot was added before or after a lock boundary. + */ + lockGeneration: number; + [key: string]: any; } @@ -44,6 +57,12 @@ export interface SlotsContext { * Indicator if a `components` override has been applied to the parent or current component. */ override: boolean; + + /** + * Tracks the lock generation for each slot. + * Maps slot name to the lockGeneration value when the slot was first assigned. + */ + slotGenerations: Record; } export interface BoxContext { @@ -96,13 +115,16 @@ export function defaults(root?: RootNode): BoxContext { env: { components: {}, sprite: '', + locked: false, + lockGeneration: 0, document: () => getDocument(root), window: () => getWindow(root) }, slots: { override: false, namespace: [], - assigned: {} + assigned: {}, + slotGenerations: {} } }; } diff --git a/packages/environment/examples/lock-no-override.tsx b/packages/environment/examples/lock-no-override.tsx new file mode 100644 index 000000000..866215d94 --- /dev/null +++ b/packages/environment/examples/lock-no-override.tsx @@ -0,0 +1,77 @@ +import { Environment } from '@bento/environment'; +import { withSlots } from '@bento/slots'; +import { useProps } from '@bento/use-props'; +import { Button } from '@bento/button'; +import { Container } from '@bento/container'; +import { RadioGroup, Radio } from '@bento/radio'; +/* v8 ignore next */ +import React from 'react'; + +/** + * Interface for component props. + * + * @interface ComponentProps + * @public + */ +interface ComponentProps { + [key: string]: any; +} + +/** + * Composite component with internal slots. + * + * @param {ComponentProps} props - The component props. + * @returns {JSX.Element} The rendered composed component. + * @public + */ +const Composed = withSlots('LockNoOverride.Composed', function ComposedComponent(props: ComponentProps) { + const slots = { + 'group.label': { className: 'label' }, + 'group.description': { className: 'describe' } + }; + + return ( + + + + Apple + Banana + Orange + + + ); +}); + +/** + * Design system component with lock boundary. + * + * @param {ComponentProps} props - The component props. + * @returns {JSX.Element} The rendered design system component. + * @public + */ +export const DesignSystemVersion = withSlots('LockNoOverride.DSVersion', function DSVersionComponent(props: ComponentProps) { + const { props: p } = useProps(props); + const slots = { + 'root.trigger': { + children: 'Click Me' + } + }; + + return ( + + + + ); +}); + +/** + * Example demonstrating lock with NO consumer overrides. + * Expected: NO data-override attributes anywhere. + * All slots (trigger, label, description) are internal composition. + * + * @returns {JSX.Element} The rendered example. + * @public + */ +export const LockNoOverride: React.FC = function LockNoOverride() { + return ; +}; diff --git a/packages/environment/examples/lock-with-override.tsx b/packages/environment/examples/lock-with-override.tsx new file mode 100644 index 000000000..17b80eea9 --- /dev/null +++ b/packages/environment/examples/lock-with-override.tsx @@ -0,0 +1,16 @@ +/* v8 ignore next */ +import React from 'react'; +// Reuse the components from the lock-no-override example +import { DesignSystemVersion } from './lock-no-override'; + +/** + * Example demonstrating lock WITH consumer override. + * Expected: data-override ONLY on the button (trigger slot). + * Consumer modifies the trigger slot, internal label/description are not flagged. + * + * @returns {JSX.Element} The rendered example. + * @public + */ +export const LockWithOverride: React.FC = function LockWithOverride() { + return ; +}; diff --git a/packages/environment/src/index.tsx b/packages/environment/src/index.tsx index b1faa6266..901d53782 100644 --- a/packages/environment/src/index.tsx +++ b/packages/environment/src/index.tsx @@ -35,6 +35,16 @@ export interface EnvironmentProps { */ sprite?: string; + /** + * When true, creates a lock boundary. Slot modifications applied after this + * lock will be detected and flagged with data-override attributes. + * This is used by design systems to distinguish between internal composition + * and consumer modifications. + * + * @defaultValue false + */ + lock?: boolean; + // // Catch all, to allow users specify custom configuration that can be shared // between Bento component that does need strong typing. @@ -55,6 +65,7 @@ export interface EnvironmentProps { * - If a value in the configuration is an array, it will be concatenated with the existing array in the context. * - If a value in the configuration is an object, it will be deeply merged with the existing object in the context. * - Primitive values in the configuration will overwrite the existing values in the context. + * - When `lock={true}`, the lockGeneration is incremented, creating a boundary for detecting consumer modifications. * * @example * ```tsx @@ -62,8 +73,16 @@ export interface EnvironmentProps { * * * ``` + * + * @example + * ```tsx + * // Create a lock boundary for a design system component + * + * + * + * ``` */ -export function Environment({ children, ...config }: EnvironmentProps) { +export function Environment({ children, lock = false, ...config }: EnvironmentProps) { let ctx = { ...useContext>>(Box) }; const context = useDeepCompareMemo( @@ -72,6 +91,17 @@ export function Environment({ children, ...config }: EnvironmentProps) { ctx.env = merge(ctx.env, options) as EnvContext>; + // Handle lock generation + if (lock && !ctx.env.locked) { + // First lock in the tree - set locked and increment generation + ctx.env.locked = true; + ctx.env.lockGeneration = ctx.env.lockGeneration + 1; + } else if (lock && ctx.env.locked) { + // Nested lock - increment generation + ctx.env.lockGeneration = ctx.env.lockGeneration + 1; + } + // If lock={false}, don't increment generation (inherit parent's generation) + if (root) { const { document, window } = defaults(root).env; ctx.env = merge(ctx.env, { document, window }) as EnvContext>; @@ -79,7 +109,7 @@ export function Environment({ children, ...config }: EnvironmentProps) { return ctx; }, - [ctx, config] + [ctx, config, lock] ); return {children}; diff --git a/packages/environment/test/lock-generation.browser.test.tsx b/packages/environment/test/lock-generation.browser.test.tsx new file mode 100644 index 000000000..898fb1cc4 --- /dev/null +++ b/packages/environment/test/lock-generation.browser.test.tsx @@ -0,0 +1,94 @@ +import { LockNoOverride } from '../examples/lock-no-override.tsx'; +import { LockWithOverride } from '../examples/lock-with-override.tsx'; +import { render } from 'vitest-browser-react'; +import { describe, it } from 'vitest'; +import assume from 'assume'; +/* v8 ignore next */ +import React from 'react'; + +describe('@bento/environment lock generation examples', function lockTests() { + describe('LockNoOverride', function lockNoOverride() { + it('should render without any data-override attributes', function test() { + const { container } = render(); + const result = container.innerHTML; + + // EXPECTED: NO data-override anywhere + // All slots (trigger, label, description) are internal composition at generation 1 + const overrideMatches = result.match(/data-override/g); + + assume(overrideMatches).is.null('Should have NO data-override attributes'); + }); + + it('should render button without data-override', function test() { + const { container } = render(); + const button = container.querySelector('button'); + + assume(button).is.not.null(); + assume(button?.getAttribute('data-override')).is.null(); + assume(button?.textContent).equals('Click Me'); + }); + + it('should render label without data-override', function test() { + const { container } = render(); + const label = container.querySelector('.label'); + + assume(label).is.not.null(); + assume(label?.getAttribute('data-override')).is.null(); + }); + + it('should render description without data-override', function test() { + const { container } = render(); + const description = container.querySelector('.describe'); + + assume(description).is.not.null(); + assume(description?.getAttribute('data-override')).is.null(); + }); + }); + + describe('LockWithOverride', function lockWithOverride() { + it('should have exactly ONE data-override on the button only', function test() { + const { container } = render(); + const result = container.innerHTML; + + // EXPECTED: data-override ONLY on button + // Consumer's trigger slot added at generation 0 (before lock) + // Internal label/description slots added at generation 1 (after lock) + const overrideMatches = result.match(/data-override/g); + + assume(overrideMatches).is.not.null(); + assume(overrideMatches?.length).equals(1, 'Should have exactly ONE data-override'); + }); + + it('should have data-override="slot" on button', function test() { + const { container } = render(); + const button = container.querySelector('button'); + + assume(button).is.not.null(); + assume(button?.getAttribute('data-override')).is.not.null(); + assume(button?.getAttribute('data-override')).equals('slot'); + }); + + it('should render button with consumer text', function test() { + const { container } = render(); + const button = container.querySelector('button'); + + assume(button?.textContent).equals('Hello World'); + }); + + it('should NOT have data-override on label', function test() { + const { container } = render(); + const label = container.querySelector('.label'); + + assume(label).is.not.null(); + assume(label?.getAttribute('data-override')).is.null(); + }); + + it('should NOT have data-override on description', function test() { + const { container } = render(); + const description = container.querySelector('.describe'); + + assume(description).is.not.null(); + assume(description?.getAttribute('data-override')).is.null(); + }); + }); +}); diff --git a/packages/internal-props/CHANGELOG.md b/packages/internal-props/CHANGELOG.md deleted file mode 100644 index 007db0b64..000000000 --- a/packages/internal-props/CHANGELOG.md +++ /dev/null @@ -1,7 +0,0 @@ -# @bento/internal-props - -## 0.1.0 - -### Minor Changes - -- chore: configure automated release workflow with changesets and GitHub Actions ([#168](https://github.com/godaddy/bento/pull/168) by @kbader-godaddy) diff --git a/packages/internal-props/LICENSE b/packages/internal-props/LICENSE deleted file mode 100644 index c0f0bb10b..000000000 --- a/packages/internal-props/LICENSE +++ /dev/null @@ -1,9 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2025 GoDaddy Operating Company, LLC. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/internal-props/README.mdx b/packages/internal-props/README.mdx deleted file mode 100644 index eeb699a3a..000000000 --- a/packages/internal-props/README.mdx +++ /dev/null @@ -1,69 +0,0 @@ -import { Meta, Story, ArgTypes, Source, Controls } from '@storybook/addon-docs/blocks'; -import * as Stories from './internal-props.stories.tsx'; -import SourceDemo from './examples/override-classname.tsx?raw'; - - - -# InternalProps - -The `@bento/internal-props` package provides internal utilities and components used across the Bento project to bypass restrictions, filtering of props, or triggering of data-override props within the Bento ecosystem. This package is strictly for internal use by @bento-based components or those who use Bento to create design system components. - -> ⚠️ **Warning**: This package is not intended for public consumption and is subject to change without notice. It should only be used by internal Bento components or design system implementations. - -## Installation - -To install the package, use your preferred package manager: - -```shell -npm install @bento/internal-props -``` - -## API - -The package exports two main functions: - -### useInternalProps - -Extracts internal properties from a set of properties by removing the specified prefix. - -```tsx -import { useInternalProps } from '@bento/internal-props'; - -const [props, internal] = useInternalProps({ - '$$bento-internal-1-foo': 'bar', - '$$bento-internal-1-baz': 'qux', - regular: 'prop' -}); - -console.log(props); // { regular: 'prop' } -console.log(internal); // { foo: 'bar', baz: 'qux' } -``` - -The function accepts two arguments: -- `props`: The properties to extract from -- `prefix`: (Optional) The prefix to remove from the properties. Defaults to `$$bento-internal-{major}-` - -### toInternalProps - -Converts a set of properties to internal properties by prefixing them with the specified namespace. - -```tsx -import { toInternalProps } from '@bento/internal-props'; - -const internal = toInternalProps({ - foo: 'bar', - baz: 'qux' -}); - -console.log(internal); // { '$$bento-internal-1-foo': 'bar', '$$bento-internal-1-baz': 'qux' } -``` - -The function accepts two arguments: -- `props`: The properties to convert -- `prefix`: (Optional) The prefix to use for the internal properties. Defaults to `$$bento-internal-{major}-` - -### Example - - - - diff --git a/packages/internal-props/examples/override-classname.tsx b/packages/internal-props/examples/override-classname.tsx deleted file mode 100644 index 0fed137cb..000000000 --- a/packages/internal-props/examples/override-classname.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { toInternalProps } from '@bento/internal-props'; -import { useProps } from '@bento/use-props'; -import { withSlots } from '@bento/slots'; -/* v8 ignore next */ -import React from 'react'; - -/** - * Simple button component that accepts internal props. - * - * @param {Record} args - The component props. - * @returns {JSX.Element} The rendered button element. - * @public - */ -export const BentoButton = withSlots('InternalBentoButton', function ButtonComponent(args: Record) { - const { props, apply } = useProps(args); - - return ; -}); - -/** - * Example component demonstrating how to use toInternalProps for className overrides. - * - * @returns {JSX.Element} The rendered example with various button styles. - * @public - */ -export const Example = withSlots('ClassNameOverrideExample', function ExampleComponent() { - // Convert className to internal prop to avoid data-override - const primaryButtonProps = toInternalProps({ className: 'primary-button large-button' }); - - const customButtonProps = toInternalProps({ - className: ({ original }: { original: string }) => ['custom-button-class', original].join(' ') - }); - - return ( -
- {/* Regular button with default className */} - Default Button - - {/* Button with custom className that won't trigger data-override */} - Triggers data-override - - {/* Button with custom className that won't trigger data-override */} - Custom Styled Button - - {/* Button with multiple classNames */} - Primary Large Button -
- ); -}); diff --git a/packages/internal-props/internal-props.stories.tsx b/packages/internal-props/internal-props.stories.tsx deleted file mode 100644 index d4730fded..000000000 --- a/packages/internal-props/internal-props.stories.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { getMeta, getStory } from '@bento/storybook-addon-helpers'; -import { Example as OverrideClassNameExample } from './examples/override-classname'; - -export default getMeta({ - title: 'internal/internal-props' -}); - -export const Demo = getStory(OverrideClassNameExample); diff --git a/packages/internal-props/package.json b/packages/internal-props/package.json deleted file mode 100644 index f75f5c08d..000000000 --- a/packages/internal-props/package.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "@bento/internal-props", - "version": "0.1.0", - "description": "Internal utilities for managing prefixed component properties", - "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "scripts": { - "build": "tsup-node", - "dev": "tsup-node --watch", - "lint": "biome lint && tsc --noEmit", - "posttest": "npm run lint", - "pretest": "npm run build", - "test": "vitest --run", - "test:watch": "vitest" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/godaddy/bento.git" - }, - "keywords": [ - "bento", - "component", - "hook", - "internal", - "library", - "prefix", - "props", - "react" - ], - "author": "GoDaddy Operating Company, LLC", - "license": "MIT", - "bugs": { - "url": "https://github.com/godaddy/bento/issues" - }, - "homepage": "https://github.com/godaddy/bento#readme", - "files": [ - "dist", - "src", - "package.json" - ], - "peerDependencies": { - "react": "18.x || 19.x", - "react-dom": "18.x || 19.x" - }, - "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - }, - "./package.json": "./package.json" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - } -} diff --git a/packages/internal-props/src/index.ts b/packages/internal-props/src/index.ts deleted file mode 100644 index f7d7de9a9..000000000 --- a/packages/internal-props/src/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -// -// Our build process introduces the version of the package as major, minor, and -// patch variables. -// -declare const major: string; -const namespace = ['$$bento', 'internal', major].filter(Boolean).join('-'); - -/** - * Extracts internal properties from a set of properties by removing the - * specified prefix. - * - * @param prefix - The prefix to remove from the properties. - * @param props - The properties to extract from. - * @returns A tuple containing the properties - * without the prefix and the internal properties with the prefix. - * @private - */ -function explode(prefix: string, props: Record): [Record, string[]] { - if (!prefix.endsWith('-')) prefix += '-'; - - const result: Record = {}; - const remove: string[] = []; - const keys = Object.keys(props); - - for (const key of keys) { - if (key.startsWith(prefix)) { - const newKey = key.slice(prefix.length); - - result[newKey] = props[key]; - remove.push(key); - } - } - - return [result, remove]; -} - -/** - * Extracts internal properties from a set of properties by removing the - * specified prefix. - * - * @param props - The properties to extract from. - * @param [prefix="$$bento-internal-{major}-"] - Optional prefix to remove from the properties. - * @returns A tuple containing the properties without the prefix and the internal properties with the prefix. - * @public - */ -export function useInternalProps( - props: Record, - prefix: string = namespace -): [Record, Record] { - const [internal, remove] = explode(prefix, props); - const result = { ...props }; - - for (const key of remove) { - delete result[key]; - } - - return [result, internal]; -} - -/** - * Converts a set of properties to internal properties by prefixing them with - * the specified namespace. - * - * @param props - The properties to convert. - * @param [prefix="$$bento-internal-{major}-"] - Optional prefix to use for the internal properties. - * @returns The converted properties with the prefix applied. - * @public - */ -export function toInternalProps(props: Record, prefix: string = namespace): Record { - if (!prefix.endsWith('-')) prefix += '-'; - - const result: Record = {}; - const keys = Object.keys(props); - - for (const key of keys) { - result[`${prefix}${key}`] = props[key]; - } - - return result; -} diff --git a/packages/internal-props/test/examples.browser.test.tsx b/packages/internal-props/test/examples.browser.test.tsx deleted file mode 100644 index 297274318..000000000 --- a/packages/internal-props/test/examples.browser.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Example } from '../examples/override-classname.tsx'; -import { render } from 'vitest-browser-react'; -import { describe, it } from 'vitest'; -import assume from 'assume'; -import React from 'react'; - -describe('@bento/internal-props examples', function bento() { - describe('ClassName Override Example', function classNameOverride() { - it('should render buttons with correct classNames', function test() { - const { container } = render(); - const buttons = container.querySelectorAll('button'); - - // Check default button - assume(buttons[0].className).equals('bento-button-base'); - assume(buttons[0].textContent).equals('Default Button'); - - // Check override trigger button - assume(buttons[1].className).equals('trigger-override'); - assume(buttons[1].textContent).equals('Triggers data-override'); - - // Check custom styled button - assume(buttons[2].className).equals('custom-button-class bento-button-base'); - assume(buttons[2].textContent).equals('Custom Styled Button'); - - // Check primary large button - assume(buttons[3].className).equals('primary-button large-button'); - assume(buttons[3].textContent).equals('Primary Large Button'); - }); - }); -}); diff --git a/packages/internal-props/test/internal.node.test.ts b/packages/internal-props/test/internal.node.test.ts deleted file mode 100644 index 11137e7e9..000000000 --- a/packages/internal-props/test/internal.node.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { useInternalProps, toInternalProps } from '@bento/internal-props'; -import pkg from '../package.json' with { type: 'json' }; -import { dirname, resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { describe, it } from 'vitest'; -import fs from 'node:fs/promises'; -import assume from 'assume'; - -describe('@bento/internal-props', function internalPropsTests() { - const [major] = pkg.version.split('.'); - - describe('#useInternalProps', function useInternalPropsTests() { - it('should separate internal props based on the prefix', function separateInternalProps() { - const props = { - 'prefix-key1': 'value1', - 'prefix-key2': 'value2', - otherKey: 'value3' - }; - const prefix = 'prefix-'; - - const [result, internal] = useInternalProps(props, prefix); - - assume(result).eqls({ otherKey: 'value3' }); - assume(internal).eqls({ key1: 'value1', key2: 'value2' }); - }); - - it('should handle props without matching prefix', function handleNoMatchingPrefix() { - const props = { - otherKey: 'value1', - anotherKey: 'value2' - }; - const prefix = 'prefix-'; - - const [result, internal] = useInternalProps(props, prefix); - - assume(result).eqls(props); - assume(internal).eqls({}); - }); - - it('should add a dash to the prefix if it does not end with one', function addDashToPrefix() { - const props = { - 'prefix-key1': 'value1', - 'prefix-key2': 'value2' - }; - const prefix = 'prefix'; - - const [result, internal] = useInternalProps(props, prefix); - - assume(result).eqls({}); - assume(internal).eqls({ key1: 'value1', key2: 'value2' }); - }); - - it('should handle empty props', function handleEmptyProps() { - const props = {}; - const prefix = 'prefix-'; - - const [result, internal] = useInternalProps(props, prefix); - - assume(result).eqls({}); - assume(internal).eqls({}); - }); - - it('should apply the default prefix if none is provided', function applyDefaultPrefix() { - const props = { - [`$$bento-internal-${major}-key1`]: 'value1', - [`$$bento-internal-${major}-key2`]: 'value2' - }; - - const [result, internal] = useInternalProps(props); - - assume(result).eqls({}); - assume(internal).eqls({ key1: 'value1', key2: 'value2' }); - }); - }); - - describe('#toInternalProps', function toInternalPropsTests() { - it('should prefix all keys with the given prefix', function prefixAllKeys() { - const props = { - key1: 'value1', - key2: 'value2' - }; - const prefix = 'prefix-'; - - const result = toInternalProps(props, prefix); - - assume(result).eqls({ - 'prefix-key1': 'value1', - 'prefix-key2': 'value2' - }); - }); - - it('should add a dash to the prefix if it does not end with one', function addDashToPrefixInToInternalProps() { - const props = { - key1: 'value1', - key2: 'value2' - }; - const prefix = 'prefix'; - - const result = toInternalProps(props, prefix); - - assume(result).eqls({ - 'prefix-key1': 'value1', - 'prefix-key2': 'value2' - }); - }); - - it('should handle empty props', function handleEmptyProps() { - const props = {}; - const prefix = 'prefix-'; - - const result = toInternalProps(props, prefix); - - assume(result).eqls({}); - }); - - it('applies the default prefix if none is provided', function applyDefaultPrefix() { - const props = { - key1: 'value1', - key2: 'value2' - }; - - const result = toInternalProps(props); - - assume(result).eqls({ - [`$$bento-internal-${major}-key1`]: 'value1', - [`$$bento-internal-${major}-key2`]: 'value2' - }); - }); - }); - - describe('Public API', function packageSuite() { - const __dirname = dirname(fileURLToPath(import.meta.url)); - - describe('#exports', function exportsSuite() { - Object.keys(pkg.exports).forEach(function each(subpaths) { - describe(`${subpaths}`, function subpathsSuite() { - const exportPath = (pkg.exports as any)[subpaths]; - - if (typeof exportPath === 'string') { - return it(`exports ${subpaths} exists`, async function exportedTest() { - const path = resolve(__dirname, '..', exportPath); - await fs.access(path, fs.constants.F_OK); - }); - } - - Object.keys(exportPath).forEach(function each(exported) { - Object.keys(exportPath[exported]).forEach(function each(key) { - it(`conditional export "${exported}.${key}" exists for ${join(pkg.name, subpaths)}`, async function exportedTest() { - const path = resolve(__dirname, '..', exportPath[exported][key]); - await fs.access(path, fs.constants.F_OK); - }); - }); - }); - }); - }); - }); - }); -}); diff --git a/packages/internal-props/tsconfig.json b/packages/internal-props/tsconfig.json deleted file mode 100644 index aabb3e399..000000000 --- a/packages/internal-props/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../configs/tsconfig.json", - "include": ["./src", "./test", "./examples"], - "compilerOptions": { - "rootDir": "./", - "declarationDir": "./dist" - } -} diff --git a/packages/internal-props/tsup.config.ts b/packages/internal-props/tsup.config.ts deleted file mode 100644 index 817a487e1..000000000 --- a/packages/internal-props/tsup.config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { shared } from '../../configs/tsup.config.mjs'; -import { version } from './package.json'; -import { defineConfig } from 'tsup'; - -export default defineConfig({ - ...shared, - entry: ['src/index.ts'], - - /** - * Introduces the version of the package as a define variable in the build, - * allowing the code to reference major, minor, and patch versions numbers. - * - * @param {import('esbuild').BuildOptions} options - The options object passed to esbuild. - * @returns {void} - * @private - */ - esbuildOptions(options) { - // - // Package versions can include prerelease tags (e.g. 1.2.3-alpha) or build metadata - // (e.g. 1.2.3+20230315). We only want the core version numbers for build-time - // constants, so we use regex to extract just the major.minor.patch parts. - // - // @see https://semver.org/ - // - const versionMatch = version.match(/^(\d+)\.(\d+)\.(\d+)/); - const semver = versionMatch ? versionMatch.slice(1) : ['0', '0', '0']; - - options.define ??= {}; - ['major', 'minor', 'patch'].forEach(function defineVersion(key, index) { - options.define[key] = JSON.stringify(semver[index]); - }); - } -}); diff --git a/packages/internal-props/vitest.config.ts b/packages/internal-props/vitest.config.ts deleted file mode 100644 index 3ca0424fa..000000000 --- a/packages/internal-props/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import sharedConfig, { ssr, browser } from '../../configs/vitest.config.mts'; -import { defineConfig, mergeConfig } from 'vitest/config'; - -export default mergeConfig( - sharedConfig, - defineConfig({ - test: { - projects: [ssr, browser] - } - }) -); diff --git a/packages/slots/src/override.ts b/packages/slots/src/override.ts index bb12e6b24..f5c646d6f 100644 --- a/packages/slots/src/override.ts +++ b/packages/slots/src/override.ts @@ -49,6 +49,8 @@ const triggers: string[] = ['className', 'style']; /** * Overrides the properties of a given context based on certain conditions. + * When the environment is locked, only flags slots that were added before + * the lock boundary (have a lower lockGeneration). * * @param args.context - The context object. * @param args.props - The properties object. @@ -60,15 +62,27 @@ export function override>({ props }: OverrideArgs): OverrideResult | undefined { const causes: string[] = []; - const { namespace, assigned, override } = context.slots; - const slot: Record | undefined = assigned[namespace.join('.')]; + const { namespace, assigned, override: overrideFlag } = context.slots; + const currentNamespace = namespace.join('.'); + const slot: Record | undefined = assigned[currentNamespace]; + + // Get lock state + const isLocked = context.env?.locked ?? false; + const currentLockGeneration = context.env?.lockGeneration ?? 0; + const slotGeneration = context.slots?.slotGenerations?.[currentNamespace] ?? currentLockGeneration; if (typeof props['data-override'] === 'string') { causes.push(...props['data-override'].split(' ')); } - if (override && !causes.includes('context')) causes.push('context'); - if ('className' in props && !causes.includes('className')) causes.push('className'); + if (overrideFlag && !causes.includes('context')) causes.push('context'); + + // Only flag className if environment is NOT locked, or if slot is from earlier generation + if ('className' in props && !causes.includes('className')) { + if (!isLocked || slotGeneration < currentLockGeneration) { + causes.push('className'); + } + } // // For style we need to take a more sophisticated approach, users are allowed @@ -80,11 +94,28 @@ export function override>({ const keys = Object.keys(style); if (keys.some((key) => !isCSSVariable(key))) { - causes.push('style'); + if (!isLocked || slotGeneration < currentLockGeneration) { + causes.push('style'); + } } } - if (slot) { + // Only flag slot modifications if: + // 1. Environment is locked + // 2. The slot's generation is less than the current lock generation + if (slot && isLocked && slotGeneration < currentLockGeneration) { + // Any slot modification from an earlier generation should be flagged + if (!causes.includes('slot')) { + causes.push('slot'); + } + // Also add specific triggers if present + Object.keys(slot).forEach(function forEach(name) { + if (triggers.includes(name) && !causes.includes(name)) { + causes.push(name); + } + }); + } else if (slot && !isLocked) { + // Original behavior when not locked Object.keys(slot).forEach(function forEach(name) { if (triggers.includes(name) && !causes.includes(name)) causes.push(name); if (!causes.includes('slot')) causes.push('slot'); diff --git a/packages/slots/src/slots.tsx b/packages/slots/src/slots.tsx index 15d215d1e..7bdab2253 100644 --- a/packages/slots/src/slots.tsx +++ b/packages/slots/src/slots.tsx @@ -71,6 +71,7 @@ export function withSlots( const currentNamespace = ctx.slots.namespace.join('.'); const inheritedSlots: Record = {}; + const inheritedGenerations: Record = {}; const prefix = `${currentNamespace}.`; // @@ -81,17 +82,26 @@ export function withSlots( for (const key in ctx.slots.assigned) { if (currentNamespace === '' && !key.includes('.')) { inheritedSlots[key] = ctx.slots.assigned[key]; + if (ctx.slots.slotGenerations && key in ctx.slots.slotGenerations) { + inheritedGenerations[key] = ctx.slots.slotGenerations[key]; + } } else if (key === currentNamespace || key.startsWith(prefix)) { inheritedSlots[key] = ctx.slots.assigned[key]; + if (ctx.slots.slotGenerations && key in ctx.slots.slotGenerations) { + inheritedGenerations[key] = ctx.slots.slotGenerations[key]; + } } } ctx.slots.assigned = inheritedSlots; + ctx.slots.slotGenerations = inheritedGenerations; // // merge the new slots with the assigned slots, // parent component slots should take precedence over child ones. // + const currentGeneration = ctx.env.lockGeneration || 0; + for (const slotKey in slots) { // Build the fully qualified slot key by prefixing with current namespace const namespacedKey = ctx.slots.namespace.length > 0 ? `${currentNamespace}.${slotKey}` : slotKey; @@ -104,8 +114,19 @@ export function withSlots( // if (!assignedSlot) { ctx.slots.assigned[namespacedKey] = newSlot; + // Tag new slot with current generation + ctx.slots.slotGenerations = ctx.slots.slotGenerations || {}; + ctx.slots.slotGenerations[namespacedKey] = currentGeneration; } else if (typeof assignedSlot === 'object') { ctx.slots.assigned[namespacedKey] = { ...newSlot, ...assignedSlot }; + // Keep the earliest (lowest) generation when merging + // If the slot doesn't have a generation yet, use current generation + ctx.slots.slotGenerations = ctx.slots.slotGenerations || {}; + if (!(namespacedKey in ctx.slots.slotGenerations)) { + ctx.slots.slotGenerations[namespacedKey] = currentGeneration; + } + // If newSlot is from an earlier generation (consumer slot), update the tag + // We assume parent slots (assignedSlot) are from consumer, so keep their generation } else if (typeof assignedSlot === 'function') { const existingPrevious = assignedSlot.__slotPrevious || []; const newPrevious = [newSlot, ...existingPrevious]; @@ -115,6 +136,11 @@ export function withSlots( mergedFnSlot.__slotPrevious = newPrevious; ctx.slots.assigned[namespacedKey] = mergedFnSlot; + // For functions, keep the parent function's generation (it takes precedence) + ctx.slots.slotGenerations = ctx.slots.slotGenerations || {}; + if (!(namespacedKey in ctx.slots.slotGenerations)) { + ctx.slots.slotGenerations[namespacedKey] = currentGeneration; + } } } diff --git a/packages/slots/test/override.node.test.ts b/packages/slots/test/override.node.test.ts index 65d4aabe9..5526e9eb2 100644 --- a/packages/slots/test/override.node.test.ts +++ b/packages/slots/test/override.node.test.ts @@ -133,4 +133,118 @@ describe('@bento/slots override', function bento() { assume(html).equals('

Should have data-override

'); }); + + describe('lock-based override detection', function lockBasedOverrides() { + it('does not flag className when environment is locked and slot is from same generation', function lockedSameGen() { + const TestComponent = withSlots('LockSameGen', function Component(args: any) { + return React.createElement('div', { ...args }); + }); + + const value = defaults(); + value.env.locked = true; + value.env.lockGeneration = 1; + value.slots.assigned = { test: {} }; + value.slots.slotGenerations = { test: 1 }; // Same generation + + const html = renderToString( + React.createElement( + Box.Provider, + { value }, + React.createElement(TestComponent, { slot: 'test', className: 'internal' }) + ) + ); + + // Should NOT have data-override since it's internal composition (same generation) + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); + + it('flags className when environment is locked and slot is from earlier generation', function lockedEarlierGen() { + const TestComponent = withSlots('LockEarlierGen', function Component(args: any) { + return React.createElement('div', { ...args }); + }); + + const value = defaults(); + value.env.locked = true; + value.env.lockGeneration = 2; + value.slots.assigned = { test: {} }; + value.slots.slotGenerations = { test: 0 }; // Earlier generation (consumer) + + const html = renderToString( + React.createElement( + Box.Provider, + { value }, + React.createElement(TestComponent, { slot: 'test', className: 'consumer' }) + ) + ); + + // SHOULD have data-override since it's from earlier generation (consumer modification) + assume(html).contains('
'); + }); + + it('flags slot modifications from earlier generation even without className or style', function slotOnly() { + const TestComponent = withSlots('LockSlotOnly', function Component(args: any) { + return React.createElement('div', { ...args }); + }); + + const value = defaults(); + value.env.locked = true; + value.env.lockGeneration = 2; + value.slots.assigned = { test: { children: 'Hello' } }; + value.slots.slotGenerations = { test: 0 }; // Earlier generation + + const html = renderToString( + React.createElement(Box.Provider, { value }, React.createElement(TestComponent, { slot: 'test' })) + ); + + // SHOULD have data-override="slot" since slot is from earlier generation + assume(html).contains('
'); + }); + + it('does not flag slots when not locked', function notLocked() { + const TestComponent = withSlots('NotLocked', function Component(args: any) { + return React.createElement('div', { ...args }); + }); + + const value = defaults(); + value.env.locked = false; + value.env.lockGeneration = 0; + value.slots.assigned = { test: { className: 'test' } }; + value.slots.slotGenerations = { test: 0 }; + + const html = renderToString( + React.createElement( + Box.Provider, + { value }, + React.createElement(TestComponent, { slot: 'test', style: { color: 'red' } }) + ) + ); + + // Original behavior: flags className and style + assume(html).contains('
'); + }); + + it('flags style modifications from earlier generation when locked', function lockedStyleEarlier() { + const TestComponent = withSlots('LockStyleEarlier', function Component(args: any) { + return React.createElement('div', { ...args }); + }); + + const value = defaults(); + value.env.locked = true; + value.env.lockGeneration = 3; + value.slots.assigned = { test: {} }; + value.slots.slotGenerations = { test: 1 }; // Earlier generation + + const html = renderToString( + React.createElement( + Box.Provider, + { value }, + React.createElement(TestComponent, { slot: 'test', style: { color: 'blue' } }) + ) + ); + + // SHOULD have data-override for style from earlier generation + assume(html).contains('
'); + }); + }); }); diff --git a/packages/slots/test/slots.node.test.ts b/packages/slots/test/slots.node.test.ts index 8044ccdac..7a845a029 100644 --- a/packages/slots/test/slots.node.test.ts +++ b/packages/slots/test/slots.node.test.ts @@ -110,6 +110,69 @@ describe('@bento/slots', function bento() { }); }); + describe('generation tracking', function generationTracking() { + it('verifies generation tracking is initialized', function initialization() { + const ctx = defaults(); + + // Check that generation tracking properties exist + assume(ctx.env.lockGeneration).equals(0); + assume(ctx.env.locked).equals(false); + assume(ctx.slots.slotGenerations).is.a('object'); + }); + + it('slots are correctly applied and tracked', function application() { + const Child = withSlots('TrackChild', (props: any) => { + return React.createElement('span', props, 'child'); + }); + + const Parent = withSlots('TrackParent', (props: any) => { + return React.createElement('div', props, React.createElement(Child, { slot: 'child' })); + }); + + const ctx = defaults(); + ctx.env.lockGeneration = 1; + + const html = renderToString( + React.createElement( + Box.Provider, + { value: ctx }, + React.createElement(Parent, { + slot: 'parent', + slots: { child: { className: 'tracked' } } + }) + ) + ); + + // Verify slot is applied (with data-override since lockGeneration > 0) + assume(html).contains('data-override="className slot"'); + assume(html).contains('child'); + }); + + it('generation tracking does not break existing functionality', function noBreakage() { + const Component = withSlots('NoBreakTest', (props: any) => { + return React.createElement('div', props, props.children); + }); + + const ctx = defaults(); + + const html = renderToString( + React.createElement( + Box.Provider, + { value: ctx }, + React.createElement(Component, { + id: 'test', + className: 'example' + }, 'content') + ) + ); + + // Verify normal rendering works + assume(html).contains('id="test"'); + assume(html).contains('class="example"'); + assume(html).contains('content'); + }); + }); + describe('modifiers', function modifiers() { it('allows custom modifiers to be passed', function custom() { let ran = false; diff --git a/packages/use-props/src/index.ts b/packages/use-props/src/index.ts index 66ac61572..fb8c596a1 100644 --- a/packages/use-props/src/index.ts +++ b/packages/use-props/src/index.ts @@ -1,5 +1,4 @@ import { Box, type BoxContext } from '@bento/box'; -import { useInternalProps } from '@bento/internal-props'; import { AnyObject } from '@bento/types'; import { useContext } from 'react'; @@ -116,11 +115,11 @@ export interface Returns { */ export function useProps(args: AnyObject, state: object = {}): Returns { const { slots } = useContext>(Box); - const [props, internal] = useInternalProps(args); + const props = args; const { namespace, assigned } = slots; const dot = namespace.join('.'); const slotted = assigned[dot] || {}; - const propsy = { ...internal, ...props, ...slotted }; + const propsy = { ...props, ...slotted }; /** * Applies the given attributes to an object. @@ -140,7 +139,7 @@ export function useProps(args: AnyObject, state: object = {}): Returns { memo[key] = renderProp(key, { original: data[key], slots: slotted, - props: { ...props, ...internal }, + props, state }); @@ -157,7 +156,7 @@ export function useProps(args: AnyObject, state: object = {}): Returns { return renderProp(name, { original: isRenderProp(name, props[name]) ? undefined : props[name], slots: slotted, - props: { ...props, ...internal }, + props, state }); } diff --git a/packages/use-props/test/use-props.node.test.tsx b/packages/use-props/test/use-props.node.test.tsx index 94ae09e4c..55dc9d650 100644 --- a/packages/use-props/test/use-props.node.test.tsx +++ b/packages/use-props/test/use-props.node.test.tsx @@ -239,6 +239,25 @@ describe('@bento/use-props', function bento() { assume(props.title).equals('my title'); }); + + it('merges slot props directly without internal-props separation', function noInternalProps() { + const { props } = createComponent( + 'no-internal', + { + id: 'component', + className: 'from-props' + }, + { + className: 'from-slot', + 'data-test': 'slot-data' + } + ); + + // Slot values should override component props directly + assume(props.id).equals('component'); + assume(props.className).equals('from-slot'); + assume(props['data-test']).equals('slot-data'); + }); }); describe('apply', function applying() { From ed3a184844ce933b8d0f1308161a62eb6f7788cf Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Mon, 3 Nov 2025 19:13:22 +0100 Subject: [PATCH 02/19] doc: improve docs --- packages/environment/CONCEPTS.mdx | 46 +++++++++++++++++++++++++++---- packages/environment/README.mdx | 33 +++++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/packages/environment/CONCEPTS.mdx b/packages/environment/CONCEPTS.mdx index 76ab4eb85..e3f55614c 100644 --- a/packages/environment/CONCEPTS.mdx +++ b/packages/environment/CONCEPTS.mdx @@ -3,14 +3,16 @@ import * as Stories from './environment.stories.tsx'; import ComponentLevelExample from './examples/component-level.tsx?raw'; import CustomButtonExample from './examples/custom-button.tsx?raw'; +import LockNoOverrideExample from './examples/lock-no-override.tsx?raw'; +import LockWithOverrideExample from './examples/lock-with-override.tsx?raw'; # Application level configuration -The `Environment` component is the main entry point for configuring Bento -components in your application. It allows you to set up global configurations, -such as components, and other settings that will be applied throughout your +The `Environment` component is the main entry point for configuring Bento +components in your application. It allows you to set up global configurations, +such as components, and other settings that will be applied throughout your application. This allows you to easily customize the behavior of specific set of components. @@ -25,7 +27,7 @@ Bento. This is useful when you want to customize the behavior or appearance of a specific component. You can pass a mapping of component names to their corresponding components. The keys in the object should match the component names used by Bento, and the values should be the new components you want to -use. +use. This is useful when you want to use a custom implementation of a component or when you want to use a different library altogether. For example, if you want @@ -34,7 +36,7 @@ it like this: -This will replace the default Bento button with your custom button component +This will replace the default Bento button with your custom button component throughout your application. ### Overrides @@ -65,3 +67,37 @@ application code. This is useful when you want to test a new component or a new version of a component without modifying the application code. The custom button example shows how to use a different component implementation for A/B testing: + +## Lock Boundaries + +When you're building a design system on top of Bento, you'll often compose components together internally using slots, className, and style props. Without lock boundaries, all these internal modifications get flagged with `data-override` attributes, which makes it really hard to tell the difference between your design system's internal structure and actual customizations made by people using your design system. + +Lock boundaries solve this by creating a clear line between "this is how we built it" and "this is what someone changed." Think of it as drawing a circle around your component and saying "everything inside this circle is intentional design system composition." + +### How it works + +The mechanism uses a generation counter. When you wrap a component with ``, Bento increments a generation number. Every slot modification gets tagged with the current generation number. Later, when the component renders, Bento checks: was this slot created before or after the lock? If it was created before (lower generation number), it came from a consumer, so it gets flagged. If it was created after (same or higher generation), it's part of your design system's internal structure, so no flag. + +Here's a practical example. Let's say you're building a design system and you create a component that internally uses a Button and RadioGroup: + + + +Notice the `` wrapper. Everything you do with slots inside that wrapper is considered internal composition. The `root.trigger` slot that changes the button text, the `group.label` and `group.description` slots that style the radio group—none of these get `data-override` attributes because they're all happening after the lock. + +Now when someone uses your design system component and wants to customize it: + + + +This slot modification happens *before* entering your locked environment (because it's passed as a prop to `DesignSystemVersion`). Bento sees it crossed the lock boundary and adds `data-override="slot"`. Now you know exactly what your consumers are customizing. + +### When to use this + +Use lock boundaries when you're building a design system that other developers will consume. If you're just building application components or working directly with Bento primitives, you probably don't need this. The lock is specifically for the boundary between design system authors and design system consumers. + +You might skip locks if you're debugging and want to see all modifications flagged, or if you're building one-off components that won't be reused across teams. + +### What you can do with this information + +Once you have accurate `data-override` tracking, you can build tooling around it. You could create a development mode that warns when consumers override certain slots, track which components are being customized most often to inform your roadmap, or generate migration guides when you need to make breaking changes by analyzing which overrides exist in production. + +The `data-override` attributes are just data attributes in the DOM. Your dev tools, browser extensions, or analytics can read them to understand how your design system is being used in the wild. diff --git a/packages/environment/README.mdx b/packages/environment/README.mdx index 2ba535105..205dc1e88 100644 --- a/packages/environment/README.mdx +++ b/packages/environment/README.mdx @@ -5,6 +5,7 @@ import OverridePropsExample from './examples/override-props.tsx?raw'; import CustomButtonExample from './examples/custom-button.tsx?raw'; import ComponentLevelExample from './examples/component-level.tsx?raw'; import IframeRenderingExample from './examples/iframe-rendering.tsx?raw'; +import LockNoOverrideExample from './examples/lock-no-override.tsx?raw'; @@ -55,7 +56,7 @@ context. In the example below we are using the `react-frame-component` package to render our components in an iframe. By passing the new `document` object as `root` -prop, we can ensure that the components that reference the `document` and +prop, we can ensure that the components that reference the `document` and `window` objects will correctly access the new document and window objects. @@ -94,3 +95,33 @@ can do it like this: + +## Lock Boundaries + +If you're building a design system on top of Bento and distributing components to other teams, the `lock` prop helps you distinguish between your internal component composition and modifications made by your consumers. + +The problem this solves is simple: when you compose components internally using slots, all those modifications show up with `data-override` attributes. This makes it impossible to tell which modifications are part of your design system's intentional structure versus which ones are customizations made by the people using your components. + +Here's what it looks like in practice: + + + +The `` wrapper tells Bento that everything inside is internal to your design system. When you modify slots within that boundary (like changing the button text or adding className to the radio labels), those modifications won't get flagged with `data-override`. + +But when someone using your design system passes their own slots: + +```tsx + +``` + +That modification happens outside your lock boundary, so it gets flagged with `data-override="slot"`. Now you can tell the difference. + +This information becomes useful when you want to understand how your design system is actually being used. You could build dev tools that warn about certain overrides, track usage patterns to inform your roadmap, or generate migration guides based on which slots people are actually customizing in production. + +For more details about how the generation tracking system works and when to use locks, see the [Concepts](/docs/environment-concepts--docs) page. + +## Props + + From d1dff30625c3f03f70a9f5bb0f59f72f496b3817 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Mon, 3 Nov 2025 21:33:59 +0100 Subject: [PATCH 03/19] fix: update packages with removal of internal props --- apps/docs/package.json | 1 - package-lock.json | 1 - packages/slots/test/slots.node.test.ts | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index 311159b76..d19c5ceb5 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -22,7 +22,6 @@ "@bento/heading": "*", "@bento/icon": "*", "@bento/illustration": "*", - "@bento/internal-props": "*", "@bento/listbox": "*", "@bento/pressable": "*", "@bento/radio": "*", diff --git a/package-lock.json b/package-lock.json index 43d9a0a0f..add596b75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,6 @@ "@bento/heading": "*", "@bento/icon": "*", "@bento/illustration": "*", - "@bento/internal-props": "*", "@bento/listbox": "*", "@bento/pressable": "*", "@bento/radio": "*", diff --git a/packages/slots/test/slots.node.test.ts b/packages/slots/test/slots.node.test.ts index 7a845a029..1d6b20b15 100644 --- a/packages/slots/test/slots.node.test.ts +++ b/packages/slots/test/slots.node.test.ts @@ -113,7 +113,7 @@ describe('@bento/slots', function bento() { describe('generation tracking', function generationTracking() { it('verifies generation tracking is initialized', function initialization() { const ctx = defaults(); - + // Check that generation tracking properties exist assume(ctx.env.lockGeneration).equals(0); assume(ctx.env.locked).equals(false); @@ -154,7 +154,7 @@ describe('@bento/slots', function bento() { }); const ctx = defaults(); - + const html = renderToString( React.createElement( Box.Provider, From c95d85481e59e2c7b86630ebc6d868ac166f19ba Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Tue, 4 Nov 2025 01:10:15 +0100 Subject: [PATCH 04/19] fix: use env and use-props as part of tests --- packages/slots/test/override.node.test.ts | 130 ++++++++++++---------- 1 file changed, 73 insertions(+), 57 deletions(-) diff --git a/packages/slots/test/override.node.test.ts b/packages/slots/test/override.node.test.ts index 5526e9eb2..0b06f4e39 100644 --- a/packages/slots/test/override.node.test.ts +++ b/packages/slots/test/override.node.test.ts @@ -3,6 +3,8 @@ import { renderToString } from 'react-dom/server'; import { withSlots } from '@bento/slots'; import { describe, it } from 'vitest'; import { Box, defaults } from '@bento/box'; +import { Environment } from '@bento/environment'; +import { useProps } from '@bento/use-props'; import assume from 'assume'; import React from 'react'; @@ -137,21 +139,15 @@ describe('@bento/slots override', function bento() { describe('lock-based override detection', function lockBasedOverrides() { it('does not flag className when environment is locked and slot is from same generation', function lockedSameGen() { const TestComponent = withSlots('LockSameGen', function Component(args: any) { - return React.createElement('div', { ...args }); + const { props } = useProps(args); + return React.createElement('div', props); }); - const value = defaults(); - value.env.locked = true; - value.env.lockGeneration = 1; - value.slots.assigned = { test: {} }; - value.slots.slotGenerations = { test: 1 }; // Same generation - const html = renderToString( - React.createElement( - Box.Provider, - { value }, - React.createElement(TestComponent, { slot: 'test', className: 'internal' }) - ) + React.createElement(Environment, { + lock: true, + children: React.createElement(TestComponent, { slot: 'test', className: 'internal' }) + }) ); // Should NOT have data-override since it's internal composition (same generation) @@ -160,22 +156,27 @@ describe('@bento/slots override', function bento() { }); it('flags className when environment is locked and slot is from earlier generation', function lockedEarlierGen() { - const TestComponent = withSlots('LockEarlierGen', function Component(args: any) { - return React.createElement('div', { ...args }); + const InnerComponent = withSlots('LockEarlierGen', function Component(args: any) { + const { props } = useProps(args); + return React.createElement('div', props); }); - const value = defaults(); - value.env.locked = true; - value.env.lockGeneration = 2; - value.slots.assigned = { test: {} }; - value.slots.slotGenerations = { test: 0 }; // Earlier generation (consumer) + const LockedDesignComponent = withSlots('LockEarlierGenDesign', function Component(props: any) { + return React.createElement(Environment, { + lock: true, + children: React.createElement(InnerComponent, { ...props, slot: 'test' }) + }); + }); const html = renderToString( - React.createElement( - Box.Provider, - { value }, - React.createElement(TestComponent, { slot: 'test', className: 'consumer' }) - ) + React.createElement(Environment, { + children: React.createElement(LockedDesignComponent, { + className: 'consumer', + slots: { + test: {} + } + }) + }) ); // SHOULD have data-override since it's from earlier generation (consumer modification) @@ -183,41 +184,51 @@ describe('@bento/slots override', function bento() { }); it('flags slot modifications from earlier generation even without className or style', function slotOnly() { - const TestComponent = withSlots('LockSlotOnly', function Component(args: any) { - return React.createElement('div', { ...args }); + const InnerComponent = withSlots('LockSlotOnly', function Component(args: any) { + const { props } = useProps(args); + return React.createElement('div', props); }); - const value = defaults(); - value.env.locked = true; - value.env.lockGeneration = 2; - value.slots.assigned = { test: { children: 'Hello' } }; - value.slots.slotGenerations = { test: 0 }; // Earlier generation + const LockedDesignComponent = withSlots('LockSlotOnlyDesign', function Component(props: any) { + return React.createElement(Environment, { + lock: true, + children: React.createElement(InnerComponent, { ...props, slot: 'test' }) + }); + }); const html = renderToString( - React.createElement(Box.Provider, { value }, React.createElement(TestComponent, { slot: 'test' })) + React.createElement(Environment, { + children: React.createElement(LockedDesignComponent, { + slots: { + test: { children: 'Hello' } + } + }) + }) ); // SHOULD have data-override="slot" since slot is from earlier generation - assume(html).contains('
'); + assume(html).contains('
Hello
'); }); it('does not flag slots when not locked', function notLocked() { - const TestComponent = withSlots('NotLocked', function Component(args: any) { - return React.createElement('div', { ...args }); + const InnerComponent = withSlots('NotLocked', function Component(args: any) { + const { props } = useProps(args); + return React.createElement('div', props); }); - const value = defaults(); - value.env.locked = false; - value.env.lockGeneration = 0; - value.slots.assigned = { test: { className: 'test' } }; - value.slots.slotGenerations = { test: 0 }; + const Component = withSlots('NotLockedDesign', function Component(props: any) { + return React.createElement(InnerComponent, { ...props, slot: 'test' }); + }); const html = renderToString( - React.createElement( - Box.Provider, - { value }, - React.createElement(TestComponent, { slot: 'test', style: { color: 'red' } }) - ) + React.createElement(Environment, { + children: React.createElement(Component, { + style: { color: 'red' }, + slots: { + test: { className: 'test' } + } + }) + }) ); // Original behavior: flags className and style @@ -225,22 +236,27 @@ describe('@bento/slots override', function bento() { }); it('flags style modifications from earlier generation when locked', function lockedStyleEarlier() { - const TestComponent = withSlots('LockStyleEarlier', function Component(args: any) { - return React.createElement('div', { ...args }); + const InnerComponent = withSlots('LockStyleEarlier', function Component(args: any) { + const { props } = useProps(args); + return React.createElement('div', props); }); - const value = defaults(); - value.env.locked = true; - value.env.lockGeneration = 3; - value.slots.assigned = { test: {} }; - value.slots.slotGenerations = { test: 1 }; // Earlier generation + const LockedDesignComponent = withSlots('LockStyleEarlierDesign', function Component(props: any) { + return React.createElement(Environment, { + lock: true, + children: React.createElement(InnerComponent, { ...props, slot: 'test' }) + }); + }); const html = renderToString( - React.createElement( - Box.Provider, - { value }, - React.createElement(TestComponent, { slot: 'test', style: { color: 'blue' } }) - ) + React.createElement(Environment, { + children: React.createElement(LockedDesignComponent, { + style: { color: 'blue' }, + slots: { + test: {} + } + }) + }) ); // SHOULD have data-override for style from earlier generation From 18135e627a8cf9c1399c74343f8eea583631eed6 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Tue, 4 Nov 2025 01:17:16 +0100 Subject: [PATCH 05/19] fix: corrected assertion now that we properly merge slots --- packages/slots/test/override.node.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/slots/test/override.node.test.ts b/packages/slots/test/override.node.test.ts index 0b06f4e39..b5e9f3bab 100644 --- a/packages/slots/test/override.node.test.ts +++ b/packages/slots/test/override.node.test.ts @@ -206,7 +206,6 @@ describe('@bento/slots override', function bento() { }) ); - // SHOULD have data-override="slot" since slot is from earlier generation assume(html).contains('
Hello
'); }); @@ -231,8 +230,7 @@ describe('@bento/slots override', function bento() { }) ); - // Original behavior: flags className and style - assume(html).contains('
'); + assume(html).contains('
'); }); it('flags style modifications from earlier generation when locked', function lockedStyleEarlier() { @@ -259,7 +257,6 @@ describe('@bento/slots override', function bento() { }) ); - // SHOULD have data-override for style from earlier generation assume(html).contains('
'); }); }); From 2221d3d43a79baec6a45b4f07874d8a45c5d5bb5 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Tue, 4 Nov 2025 01:29:08 +0100 Subject: [PATCH 06/19] fix: clean up references and dead branches --- apps/docs/.storybook/main.ts | 14 -------------- package-lock.json | 6 +----- packages/slots/src/slots.tsx | 2 -- packages/slots/test/slots.node.test.ts | 12 ++++++++---- packages/use-props/package.json | 1 - 5 files changed, 9 insertions(+), 26 deletions(-) diff --git a/apps/docs/.storybook/main.ts b/apps/docs/.storybook/main.ts index 1e538183b..2828fca86 100644 --- a/apps/docs/.storybook/main.ts +++ b/apps/docs/.storybook/main.ts @@ -4,7 +4,6 @@ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import { mergeConfig } from 'vite'; -import packageJson from '@bento/internal-props/package.json' with { type: 'json' }; const __dirname = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); @@ -54,23 +53,10 @@ const config: StorybookConfig = { }, async viteFinal(config) { - const versionMatch = packageJson.version.match(/^(\d+)\.(\d+)\.(\d+)/); - const semver = versionMatch ? versionMatch.slice(1) : ['0', '0', '0']; - - const define = { - major: semver[0], - minor: semver[1], - patch: semver[2] - }; - return mergeConfig(config, { // Set envDir to workspace root for proper monorepo support envDir: resolve(__dirname, '../../../'), - esbuild: { - define - }, - // Exclude @bento packages from optimization so they can be watched and hot-reloaded optimizeDeps: { exclude: ['@bento/*'] diff --git a/package-lock.json b/package-lock.json index add596b75..d90aaca36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -483,10 +483,6 @@ "resolved": "packages/illustration", "link": true }, - "node_modules/@bento/internal-props": { - "resolved": "packages/internal-props", - "link": true - }, "node_modules/@bento/listbox": { "resolved": "packages/listbox", "link": true @@ -13289,6 +13285,7 @@ "packages/internal-props": { "name": "@bento/internal-props", "version": "0.1.0", + "extraneous": true, "license": "MIT", "peerDependencies": { "react": "18.x || 19.x", @@ -13443,7 +13440,6 @@ "license": "MIT", "dependencies": { "@bento/box": "^0.1.0", - "@bento/internal-props": "^0.1.0", "@bento/types": "^0.1.0" }, "peerDependencies": { diff --git a/packages/slots/src/slots.tsx b/packages/slots/src/slots.tsx index 7bdab2253..6580c4a0a 100644 --- a/packages/slots/src/slots.tsx +++ b/packages/slots/src/slots.tsx @@ -115,13 +115,11 @@ export function withSlots( if (!assignedSlot) { ctx.slots.assigned[namespacedKey] = newSlot; // Tag new slot with current generation - ctx.slots.slotGenerations = ctx.slots.slotGenerations || {}; ctx.slots.slotGenerations[namespacedKey] = currentGeneration; } else if (typeof assignedSlot === 'object') { ctx.slots.assigned[namespacedKey] = { ...newSlot, ...assignedSlot }; // Keep the earliest (lowest) generation when merging // If the slot doesn't have a generation yet, use current generation - ctx.slots.slotGenerations = ctx.slots.slotGenerations || {}; if (!(namespacedKey in ctx.slots.slotGenerations)) { ctx.slots.slotGenerations[namespacedKey] = currentGeneration; } diff --git a/packages/slots/test/slots.node.test.ts b/packages/slots/test/slots.node.test.ts index 1d6b20b15..d34deb9f3 100644 --- a/packages/slots/test/slots.node.test.ts +++ b/packages/slots/test/slots.node.test.ts @@ -159,10 +159,14 @@ describe('@bento/slots', function bento() { React.createElement( Box.Provider, { value: ctx }, - React.createElement(Component, { - id: 'test', - className: 'example' - }, 'content') + React.createElement( + Component, + { + id: 'test', + className: 'example' + }, + 'content' + ) ) ); diff --git a/packages/use-props/package.json b/packages/use-props/package.json index 1af327328..cb50c315d 100644 --- a/packages/use-props/package.json +++ b/packages/use-props/package.json @@ -41,7 +41,6 @@ ], "dependencies": { "@bento/box": "^0.1.0", - "@bento/internal-props": "^0.1.0", "@bento/types": "^0.1.0" }, "peerDependencies": { From 1431ebb01e911aae7acee4f20610bf166b46ace7 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Tue, 4 Nov 2025 15:55:33 +0100 Subject: [PATCH 07/19] test: clean up tests --- .../environment/examples/lock-no-override.tsx | 31 +++--- .../test/environment.browser.test.tsx | 31 ++++++ .../test/examples.browser.test.tsx | 80 ++++++++++++++++ .../test/lock-generation.browser.test.tsx | 94 ------------------- 4 files changed, 128 insertions(+), 108 deletions(-) delete mode 100644 packages/environment/test/lock-generation.browser.test.tsx diff --git a/packages/environment/examples/lock-no-override.tsx b/packages/environment/examples/lock-no-override.tsx index 866215d94..693ef9663 100644 --- a/packages/environment/examples/lock-no-override.tsx +++ b/packages/environment/examples/lock-no-override.tsx @@ -33,7 +33,7 @@ const Composed = withSlots('LockNoOverride.Composed', function ComposedComponent return ( - + Apple Banana Orange @@ -49,20 +49,23 @@ const Composed = withSlots('LockNoOverride.Composed', function ComposedComponent * @returns {JSX.Element} The rendered design system component. * @public */ -export const DesignSystemVersion = withSlots('LockNoOverride.DSVersion', function DSVersionComponent(props: ComponentProps) { - const { props: p } = useProps(props); - const slots = { - 'root.trigger': { - children: 'Click Me' - } - }; +export const DesignSystemVersion = withSlots( + 'LockNoOverride.DSVersion', + function DSVersionComponent(props: ComponentProps) { + const { props: p } = useProps(props); + const slots = { + 'root.trigger': { + children: 'Click Me' + } + }; - return ( - - - - ); -}); + return ( + + + + ); + } +); /** * Example demonstrating lock with NO consumer overrides. diff --git a/packages/environment/test/environment.browser.test.tsx b/packages/environment/test/environment.browser.test.tsx index 914984877..073d2533b 100644 --- a/packages/environment/test/environment.browser.test.tsx +++ b/packages/environment/test/environment.browser.test.tsx @@ -66,5 +66,36 @@ describe('@bento/environment', function bento() { assume(env).equals('Hello World'); }); + + it('should handle nested environments with lock boundaries', async function test() { + let firstGeneration: number; + let secondGeneration: number; + + const env = renderToString( + + + {function firstConsumer({ env }) { + firstGeneration = env.lockGeneration; + assume(env.locked).to.equal(true); + + return ( + + + {function secondConsumer({ env }) { + secondGeneration = env.lockGeneration; + assume(env.locked).to.equal(true); + return 'Nested Lock'; + }} + + + ); + }} + + + ); + + assume(env).equals('Nested Lock'); + assume(secondGeneration!).to.be.above(firstGeneration!); + }); }); }); diff --git a/packages/environment/test/examples.browser.test.tsx b/packages/environment/test/examples.browser.test.tsx index 1306e0da9..0a1b8ea91 100644 --- a/packages/environment/test/examples.browser.test.tsx +++ b/packages/environment/test/examples.browser.test.tsx @@ -3,6 +3,8 @@ import { ComponentLevelExample } from '../examples/component-level.tsx'; import { CustomButtonExample } from '../examples/custom-button.tsx'; import { OverrideProps } from '../examples/override-props.tsx'; import { Override } from '../examples/override.tsx'; +import { LockNoOverride } from '../examples/lock-no-override.tsx'; +import { LockWithOverride } from '../examples/lock-with-override.tsx'; import { render } from 'vitest-browser-react'; import { describe, it } from 'vitest'; import assume from 'assume'; @@ -178,4 +180,82 @@ describe('@bento/environment examples', function bento() { assume(secondUpdatedBgColor).equals('lightgreen'); }); }); + + describe('LockNoOverride', function lockNoOverride() { + it('should render without any data-override attributes', function test() { + const { container } = render(); + const result = container.innerHTML; + + // Should have NO data-override attributes since all slots are internal composition + assume(result).equals( + '
Favorite fruit
Pick your favorite
' + ); + }); + + it('should render the button with internal composition text', function test() { + const { container } = render(); + const button = container.querySelector('button'); + + assume(button).to.not.equal(null); + assume(button?.textContent).equals('Click Me'); + }); + + it('should render the radio group with all options', function test() { + const { container } = render(); + const radioInputs = container.querySelectorAll('input[type="radio"]'); + + assume(radioInputs.length).equals(3); + }); + + it('should have label and description with internal composition classes', function test() { + const { container } = render(); + + // Check for label element + const label = container.querySelector('.label'); + assume(label).to.not.equal(null); + + // Check for description element + const description = container.querySelector('.describe'); + assume(description).to.not.equal(null); + }); + }); + + describe('LockWithOverride', function lockWithOverride() { + it('should render with data-override only on the trigger button', function test() { + const { container } = render(); + + assume(container.innerHTML).equals( + '
Favorite fruit
Pick your favorite
' + ); + }); + + it('should render the button with consumer override text', function test() { + const { container } = render(); + const button = container.querySelector('button'); + + assume(button).to.not.equal(null); + assume(button?.textContent).equals('Hello World'); + }); + + it('should not flag internal label and description with data-override', function test() { + const { container } = render(); + const label = container.querySelector('.label'); + const description = container.querySelector('.describe'); + + // Internal composition should not be flagged with data-override + assume(label).to.not.equal(null); + assume(description).to.not.equal(null); + assume(label?.hasAttribute('data-override')).equals(false); + assume(description?.hasAttribute('data-override')).equals(false); + }); + + it('should only have data-override on consumer-modified slots', function test() { + const { container } = render(); + const elementsWithOverride = container.querySelectorAll('[data-override]'); + + // Only the trigger button should have data-override + assume(elementsWithOverride.length).equals(1); + assume(elementsWithOverride[0]?.tagName.toLowerCase()).equals('button'); + }); + }); }); diff --git a/packages/environment/test/lock-generation.browser.test.tsx b/packages/environment/test/lock-generation.browser.test.tsx deleted file mode 100644 index 898fb1cc4..000000000 --- a/packages/environment/test/lock-generation.browser.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { LockNoOverride } from '../examples/lock-no-override.tsx'; -import { LockWithOverride } from '../examples/lock-with-override.tsx'; -import { render } from 'vitest-browser-react'; -import { describe, it } from 'vitest'; -import assume from 'assume'; -/* v8 ignore next */ -import React from 'react'; - -describe('@bento/environment lock generation examples', function lockTests() { - describe('LockNoOverride', function lockNoOverride() { - it('should render without any data-override attributes', function test() { - const { container } = render(); - const result = container.innerHTML; - - // EXPECTED: NO data-override anywhere - // All slots (trigger, label, description) are internal composition at generation 1 - const overrideMatches = result.match(/data-override/g); - - assume(overrideMatches).is.null('Should have NO data-override attributes'); - }); - - it('should render button without data-override', function test() { - const { container } = render(); - const button = container.querySelector('button'); - - assume(button).is.not.null(); - assume(button?.getAttribute('data-override')).is.null(); - assume(button?.textContent).equals('Click Me'); - }); - - it('should render label without data-override', function test() { - const { container } = render(); - const label = container.querySelector('.label'); - - assume(label).is.not.null(); - assume(label?.getAttribute('data-override')).is.null(); - }); - - it('should render description without data-override', function test() { - const { container } = render(); - const description = container.querySelector('.describe'); - - assume(description).is.not.null(); - assume(description?.getAttribute('data-override')).is.null(); - }); - }); - - describe('LockWithOverride', function lockWithOverride() { - it('should have exactly ONE data-override on the button only', function test() { - const { container } = render(); - const result = container.innerHTML; - - // EXPECTED: data-override ONLY on button - // Consumer's trigger slot added at generation 0 (before lock) - // Internal label/description slots added at generation 1 (after lock) - const overrideMatches = result.match(/data-override/g); - - assume(overrideMatches).is.not.null(); - assume(overrideMatches?.length).equals(1, 'Should have exactly ONE data-override'); - }); - - it('should have data-override="slot" on button', function test() { - const { container } = render(); - const button = container.querySelector('button'); - - assume(button).is.not.null(); - assume(button?.getAttribute('data-override')).is.not.null(); - assume(button?.getAttribute('data-override')).equals('slot'); - }); - - it('should render button with consumer text', function test() { - const { container } = render(); - const button = container.querySelector('button'); - - assume(button?.textContent).equals('Hello World'); - }); - - it('should NOT have data-override on label', function test() { - const { container } = render(); - const label = container.querySelector('.label'); - - assume(label).is.not.null(); - assume(label?.getAttribute('data-override')).is.null(); - }); - - it('should NOT have data-override on description', function test() { - const { container } = render(); - const description = container.querySelector('.describe'); - - assume(description).is.not.null(); - assume(description?.getAttribute('data-override')).is.null(); - }); - }); -}); From ed490476cdd52bed6520c04756591f9004e5380c Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Tue, 4 Nov 2025 16:00:22 +0100 Subject: [PATCH 08/19] major: effectively breaking how data-override worked before --- .changeset/gold-things-relate.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/gold-things-relate.md diff --git a/.changeset/gold-things-relate.md b/.changeset/gold-things-relate.md new file mode 100644 index 000000000..2bc0df0e7 --- /dev/null +++ b/.changeset/gold-things-relate.md @@ -0,0 +1,8 @@ +--- +"@bento/environment": major +"@bento/use-props": major +"@bento/slots": major +"@bento/box": major +--- + +data-override attributes are now opt-in using the Environment component From 8981a447767ac80635957b4f2b463763ef170865 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Tue, 4 Nov 2025 16:03:59 +0100 Subject: [PATCH 09/19] fix: 90%, im sad about it --- packages/slots/vitest.config.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/slots/vitest.config.ts b/packages/slots/vitest.config.ts index 2fc5573b2..08967ea2d 100644 --- a/packages/slots/vitest.config.ts +++ b/packages/slots/vitest.config.ts @@ -6,7 +6,15 @@ export default mergeConfig( defineConfig({ test: { coverage: { - exclude: ['src/index.ts'] + exclude: ['src/index.ts'], + thresholds: { + 'src/**.{ts,tsx}': { + statements: 90, + functions: 100, + branches: 90, + lines: 95 + } + } }, projects: [ssr, browser] } From 8757552bfbc34a9133d5cccdc64618fba5991a79 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Tue, 4 Nov 2025 16:28:19 +0100 Subject: [PATCH 10/19] lint: linting --- packages/slots/test/slots.node.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/slots/test/slots.node.test.ts b/packages/slots/test/slots.node.test.ts index d34deb9f3..01195d373 100644 --- a/packages/slots/test/slots.node.test.ts +++ b/packages/slots/test/slots.node.test.ts @@ -121,11 +121,11 @@ describe('@bento/slots', function bento() { }); it('slots are correctly applied and tracked', function application() { - const Child = withSlots('TrackChild', (props: any) => { + const Child = withSlots('TrackChild', function TrackChildComponent(props: any) { return React.createElement('span', props, 'child'); }); - const Parent = withSlots('TrackParent', (props: any) => { + const Parent = withSlots('TrackParent', function TrackParentComponent(props: any) { return React.createElement('div', props, React.createElement(Child, { slot: 'child' })); }); @@ -149,7 +149,7 @@ describe('@bento/slots', function bento() { }); it('generation tracking does not break existing functionality', function noBreakage() { - const Component = withSlots('NoBreakTest', (props: any) => { + const Component = withSlots('NoBreakTest', function NoBreakTestComponent(props: any) { return React.createElement('div', props, props.children); }); From 03cf185296ac8e1c3f4145091b41fd677463d9cc Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Mon, 10 Nov 2025 15:37:25 +0100 Subject: [PATCH 11/19] fix: it should only trigger on locked --- packages/slots/src/override.ts | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/slots/src/override.ts b/packages/slots/src/override.ts index f5c646d6f..58db37959 100644 --- a/packages/slots/src/override.ts +++ b/packages/slots/src/override.ts @@ -66,8 +66,11 @@ export function override>({ const currentNamespace = namespace.join('.'); const slot: Record | undefined = assigned[currentNamespace]; - // Get lock state - const isLocked = context.env?.locked ?? false; + // + // Data override is only supported when the environment is locked. + // + if (!(context.env?.locked ?? false)) return; + const currentLockGeneration = context.env?.lockGeneration ?? 0; const slotGeneration = context.slots?.slotGenerations?.[currentNamespace] ?? currentLockGeneration; @@ -77,11 +80,9 @@ export function override>({ if (overrideFlag && !causes.includes('context')) causes.push('context'); - // Only flag className if environment is NOT locked, or if slot is from earlier generation - if ('className' in props && !causes.includes('className')) { - if (!isLocked || slotGeneration < currentLockGeneration) { - causes.push('className'); - } + // Only flag className if slot is from an earlier generation + if ('className' in props && !causes.includes('className') && slotGeneration < currentLockGeneration) { + causes.push('className'); } // @@ -93,17 +94,13 @@ export function override>({ const style = props.style as CSSProperties; const keys = Object.keys(style); - if (keys.some((key) => !isCSSVariable(key))) { - if (!isLocked || slotGeneration < currentLockGeneration) { - causes.push('style'); - } + if (keys.some((key) => !isCSSVariable(key)) && slotGeneration < currentLockGeneration) { + causes.push('style'); } } - // Only flag slot modifications if: - // 1. Environment is locked - // 2. The slot's generation is less than the current lock generation - if (slot && isLocked && slotGeneration < currentLockGeneration) { + // Only flag slot modifications if the slot's generation is less than the current lock generation + if (slot && slotGeneration < currentLockGeneration) { // Any slot modification from an earlier generation should be flagged if (!causes.includes('slot')) { causes.push('slot'); @@ -114,12 +111,6 @@ export function override>({ causes.push(name); } }); - } else if (slot && !isLocked) { - // Original behavior when not locked - Object.keys(slot).forEach(function forEach(name) { - if (triggers.includes(name) && !causes.includes(name)) causes.push(name); - if (!causes.includes('slot')) causes.push('slot'); - }); } if (!causes.length) return; From 886759c019893c6e71aaaab90b45691511495c34 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Tue, 11 Nov 2025 00:34:59 +0100 Subject: [PATCH 12/19] fix: update env --- packages/slots/test/slots.node.test.ts | 95 ++++++++++++++++++++------ 1 file changed, 73 insertions(+), 22 deletions(-) diff --git a/packages/slots/test/slots.node.test.ts b/packages/slots/test/slots.node.test.ts index 01195d373..7debc2688 100644 --- a/packages/slots/test/slots.node.test.ts +++ b/packages/slots/test/slots.node.test.ts @@ -5,6 +5,7 @@ import { renderToString } from 'react-dom/server'; import { withSlots, library } from '@bento/slots'; import { Nested } from '../examples/nested.tsx'; import { Box, defaults } from '@bento/box'; +import { Environment } from '@bento/environment'; import { fileURLToPath } from 'node:url'; import fs from 'node:fs/promises'; import assume from 'assume'; @@ -88,21 +89,53 @@ describe('@bento/slots', function bento() { verify(); }); - it('can override the components used', function override() { - let id; + it('overrides the components used without introducing data-override when unlocked', function overrideUnlocked() { + let id: string | undefined; - const value = defaults(); - value.env.components = { - SlotsButton: function Button(props) { - assume(props['data-override']).equals('context'); - assume(props.id).startsWith(':R'); - id = props.id; + const nested = renderToString( + React.createElement( + Environment, + { + components: { + SlotsButton: function Button(props: any) { + assume(props['data-override']).is.undefined(); + assume(props.id).startsWith(':R'); + id = props.id; + + return React.createElement('p', props, 'No more button, only text'); + } + } + }, + React.createElement(Nested) + ) + ); + + assume(nested).contains('Hello World'); + assume(nested).does.not.contain('Click Me'); + assume(nested).contains(`

No more button, only text

`); + }); - return React.createElement('p', props, 'No more button, only text'); - } - }; + it('flags component overrides with context when locked', function overrideLocked() { + let id: string | undefined; - const nested = renderToString(React.createElement(Box.Provider, { value }, React.createElement(Nested))); + const nested = renderToString( + React.createElement( + Environment, + { + lock: true, + components: { + SlotsButton: function Button(props: any) { + assume(props['data-override']).equals('context'); + assume(props.id).startsWith(':R'); + id = props.id; + + return React.createElement('p', props, 'No more button, only text'); + } + } + }, + React.createElement(Nested) + ) + ); assume(nested).contains('Hello World'); assume(nested).does.not.contain('Click Me'); @@ -120,7 +153,7 @@ describe('@bento/slots', function bento() { assume(ctx.slots.slotGenerations).is.a('object'); }); - it('slots are correctly applied and tracked', function application() { + it('slots are correctly applied and tracked when locked', function applicationLocked() { const Child = withSlots('TrackChild', function TrackChildComponent(props: any) { return React.createElement('span', props, 'child'); }); @@ -129,25 +162,43 @@ describe('@bento/slots', function bento() { return React.createElement('div', props, React.createElement(Child, { slot: 'child' })); }); - const ctx = defaults(); - ctx.env.lockGeneration = 1; - const html = renderToString( - React.createElement( - Box.Provider, - { value: ctx }, - React.createElement(Parent, { + React.createElement(Environment, { + lock: true, + children: React.createElement(Parent, { slot: 'parent', slots: { child: { className: 'tracked' } } }) - ) + }) ); // Verify slot is applied (with data-override since lockGeneration > 0) - assume(html).contains('data-override="className slot"'); + assume(html).contains('data-override="slot className"'); assume(html).contains('child'); }); + it('does not flag slot tracking when environment is unlocked', function applicationUnlocked() { + const Child = withSlots('TrackChildUnlocked', function TrackChildComponent(props: any) { + return React.createElement('span', props, 'child'); + }); + + const Parent = withSlots('TrackParentUnlocked', function TrackParentComponent(props: any) { + return React.createElement('div', props, React.createElement(Child, { slot: 'child' })); + }); + + const html = renderToString( + React.createElement(Environment, { + children: React.createElement(Parent, { + slot: 'parent', + slots: { child: { className: 'tracked' } } + }) + }) + ); + + assume(html).contains(''); + assume(html).does.not.contain('data-override'); + }); + it('generation tracking does not break existing functionality', function noBreakage() { const Component = withSlots('NoBreakTest', function NoBreakTestComponent(props: any) { return React.createElement('div', props, props.children); From 2f43a67128f479dd754f0bb49a82f10cd7b3c864 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Fri, 14 Nov 2025 17:45:38 +0100 Subject: [PATCH 13/19] fix: data-override should be disabled by default --- packages/slots/test/examples.browser.test.tsx | 20 +- packages/slots/test/override.node.test.ts | 316 ++++++++++++------ packages/slots/test/replace.node.test.ts | 241 +++++++++---- 3 files changed, 411 insertions(+), 166 deletions(-) diff --git a/packages/slots/test/examples.browser.test.tsx b/packages/slots/test/examples.browser.test.tsx index 644de6261..5928f37d8 100644 --- a/packages/slots/test/examples.browser.test.tsx +++ b/packages/slots/test/examples.browser.test.tsx @@ -37,7 +37,7 @@ describe('@bento/slots examples', function bento() { }; assume(result).equals( - '
' + '
' ); await screen.getByRole('button', { name: 'Click me' }).click(); @@ -72,7 +72,7 @@ describe('@bento/slots examples', function bento() { }; assume(result).equals( - '
' + '
' ); await screen.getByRole('button', { name: 'Click me' }).click(); @@ -92,7 +92,7 @@ describe('@bento/slots examples', function bento() { [ '

', ' Description: ', - '

' + 'Content' + '
' + '
' + 'Content' + '
' ); }); }); diff --git a/packages/slots/test/override.node.test.ts b/packages/slots/test/override.node.test.ts index b5e9f3bab..b634017ad 100644 --- a/packages/slots/test/override.node.test.ts +++ b/packages/slots/test/override.node.test.ts @@ -9,11 +9,17 @@ import assume from 'assume'; import React from 'react'; describe('@bento/slots override', function bento() { - function createComponent(name: string, props = {}, slots = {}, components = {}) { + function createComponent(name: string, props = {}, slots = {}, components = {}, locked = false) { const TestReturn = withSlots(`BentoOverride-${name}`, function Component(args: any) { return React.createElement('div', { ...args }); }); + if (locked) { + // When locked, just pass slots directly to the component via the slots prop + const child = React.createElement(TestReturn, { slot: 'test', slots: { test: slots }, ...props }); + return renderToString(React.createElement(Environment, { lock: true, components, children: child })); + } + const value = defaults(); value.slots.assigned = { test: slots }; value.env.components = components; @@ -27,117 +33,238 @@ describe('@bento/slots override', function bento() { assume(override).is.a('function'); }); - it('does not introduce data-override by default', function noOverride() { - const html = createComponent('noOverride', { id: 'example', role: 'presentation' }); - assume(html).contains(''); - }); - - it('introduces data-override when style is present', function style() { - const html = createComponent('style', { style: { color: 'red' } }); - assume(html).contains('
'); - }); + describe('default behavior (no locked Environment)', function defaultBehavior() { + it('does not introduce data-override by default', function noOverride() { + const html = createComponent('noOverride', { id: 'example', role: 'presentation' }); + assume(html).contains(''); + assume(html).does.not.contain('data-override'); + }); - it('does not introduce a data-override when CSS variables are introduced', function cssVariables() { - const html = createComponent('cssVariables', { style: { '--color': 'red' } }); - assume(html).contains('
'); - }); + it('does not add data-override when style is present', function style() { + const html = createComponent('style', { style: { color: 'red' } }); + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); - it('introduces data-override when CSS variables and another style property are present', function cssVariablesAndStyle() { - const html = createComponent('cssVariablesAndStyle', { style: { '--color': 'red', color: 'blue' } }); - assume(html).contains('
'); - }); + it('does not introduce data-override for CSS variables', function cssVariables() { + const html = createComponent('cssVariables', { style: { '--color': 'red' } }); + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); - it('introduces data-override when className is present', function className() { - const html = createComponent('className', { className: 'example' }); - assume(html).contains('
'); - }); + it('does not add data-override when CSS variables and style are present', function cssVariablesAndStyle() { + const html = createComponent('cssVariablesAndStyle', { style: { '--color': 'red', color: 'blue' } }); + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); - it('adds to an existing data-override', function existing() { - const html = createComponent('existing', { style: { color: 'red' }, 'data-override': 'boink' }); - assume(html).contains('
'); - }); + it('does not introduce data-override when className is present', function className() { + const html = createComponent('className', { className: 'example' }); + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); - it('separates multiple causes with a space', function multiple() { - const html = createComponent('multiple', { - style: { color: 'red' }, - className: 'example' + it('does not introduce data-override when existing data-override is present', function existing() { + const html = createComponent('existing', { style: { color: 'red' }, 'data-override': 'boink' }); + assume(html).contains('
'); }); - assume(html).contains('
'); - }); + it('does not add data-override for multiple style triggers', function multiple() { + const html = createComponent('multiple', { + style: { color: 'red' }, + className: 'example' + }); - it('introduces data-override when a slot override is used', function slot() { - const html = createComponent( - 'slot', - {}, - { - style: { color: 'red' } - } - ); + assume(html).contains('
'); + assume(html).does.not.contain('data-override="'); + }); - assume(html).contains('
'); - }); + it('does not introduce data-override when a slot override is used', function slot() { + const html = createComponent( + 'slot', + {}, + { + style: { color: 'red' } + } + ); + + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); - it('introduces data-override when a component is replaced through Slot.Context', function context() { - const html = createComponent( - 'context', - { - style: { color: 'red' } - }, - {}, - { - 'BentoOverride-context': function Component(props: any) { - assume(props['data-override']).equals('context style'); - return React.createElement('div', props); + it('does not introduce data-override when a component is replaced through Slot.Context', function context() { + const html = createComponent( + 'context', + { + style: { color: 'red' } + }, + {}, + { + 'BentoOverride-context': function Component(props: any) { + assume(props['data-override']).equals(undefined); + return React.createElement('div', props); + } } - } - ); + ); - assume(html).contains('
'); + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); }); - it('inherits the context override from the parent component', function parent() { - const Kiddo = withSlots(`BentoOverride-Kiddo`, function Component(args: any) { - return React.createElement('p', args, args.children); + describe('locked Environment behavior', function lockedBehavior() { + it('introduces data-override when style is present in locked environment', function styleWithLock() { + // First need to set up a scenario where slot is from earlier generation + const InnerComponent = withSlots('StyleLockInner', function Component(args: any) { + const { props } = useProps(args); + return React.createElement('div', props); + }); + + const LockedComponent = withSlots('StyleLockOuter', function Component(props: any) { + return React.createElement(Environment, { + lock: true, + children: React.createElement(InnerComponent, { ...props, slot: 'test' }) + }); + }); + + const html = renderToString(React.createElement(LockedComponent, { style: { color: 'red' } })); + + assume(html).contains('data-override="style"'); + }); + + it('introduces data-override when className is present in locked environment', function classNameWithLock() { + const InnerComponent = withSlots('ClassLockInner', function Component(args: any) { + const { props } = useProps(args); + return React.createElement('div', props); + }); + + const LockedComponent = withSlots('ClassLockOuter', function Component(props: any) { + return React.createElement(Environment, { + lock: true, + children: React.createElement(InnerComponent, { ...props, slot: 'test' }) + }); + }); + + const html = renderToString(React.createElement(LockedComponent, { className: 'example' })); + + assume(html).contains('data-override="className"'); + }); + + it('does not introduce data-override for CSS variables in locked environment', function cssVariablesLocked() { + const InnerComponent = withSlots('CSSVarLockInner', function Component(args: any) { + const { props } = useProps(args); + return React.createElement('div', props); + }); + + const LockedComponent = withSlots('CSSVarLockOuter', function Component(props: any) { + return React.createElement(Environment, { + lock: true, + children: React.createElement(InnerComponent, { ...props, slot: 'test' }) + }); + }); + + const html = renderToString(React.createElement(LockedComponent, { style: { '--color': 'red' } })); + + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); }); - const Parent = withSlots(`BentoOverride-Parent`, function Component(args) { - return React.createElement( - 'section', - args, - React.createElement( - Kiddo, - { - id: 'example', - slot: 'kiddo' - }, - 'Hello World' - ) + it('separates multiple causes with a space in locked environment', function multipleLocked() { + const InnerComponent = withSlots('MultipleLockInner', function Component(args: any) { + const { props } = useProps(args); + return React.createElement('div', props); + }); + + const LockedComponent = withSlots('MultipleLockOuter', function Component(props: any) { + return React.createElement(Environment, { + lock: true, + children: React.createElement(InnerComponent, { ...props, slot: 'test' }) + }); + }); + + const html = renderToString( + React.createElement(LockedComponent, { + style: { color: 'red' }, + className: 'example' + }) ); + + assume(html).contains('data-override="className style"'); }); + }); - const value = defaults(); - value.env.components = { - 'BentoOverride-Parent': function Parent(props) { - assume(props['data-override']).equals('context'); - - // - // It is intentional that we are not passing down the props into - // the created element below, this is to simulate if someone - // would remove the props from the component. A child component - // should still be able to indicate that a parent was overridden. - // - return React.createElement('div', null, React.createElement(Kiddo, null, 'Should have data-override')); - } - }; - - const html = renderToString(React.createElement(Box.Provider, { value }, React.createElement(Parent))); - - assume(html).equals('

Should have data-override

'); + describe('parent component override inheritance', function parentTests() { + it('does not inherit context override without locked environment', function parentNoLock() { + const Kiddo = withSlots(`BentoOverride-Kiddo`, function Component(args: any) { + return React.createElement('p', args, args.children); + }); + + const Parent = withSlots(`BentoOverride-Parent`, function Component(args) { + return React.createElement( + 'section', + args, + React.createElement( + Kiddo, + { + id: 'example', + slot: 'kiddo' + }, + 'Hello World' + ) + ); + }); + + const value = defaults(); + value.env.components = { + 'BentoOverride-Parent': function Parent(props) { + assume(props['data-override']).equals(undefined); + return React.createElement('div', null, React.createElement(Kiddo, null, 'No data-override')); + } + }; + + const html = renderToString(React.createElement(Box.Provider, { value }, React.createElement(Parent))); + + assume(html).equals('

No data-override

'); + }); + + it('inherits context override when environment is locked', function parentWithLock() { + const Kiddo = withSlots(`BentoOverride-KiddoLocked`, function Component(args: any) { + return React.createElement('p', args, args.children); + }); + + const Parent = withSlots(`BentoOverride-ParentLocked`, function Component(args) { + return React.createElement( + 'section', + args, + React.createElement( + Kiddo, + { + id: 'example', + slot: 'kiddo' + }, + 'Hello World' + ) + ); + }); + + const value = defaults(); + value.env.locked = true; + value.env.lockGeneration = 1; + value.env.components = { + 'BentoOverride-ParentLocked': function Parent(props) { + assume(props['data-override']).equals('context'); + return React.createElement('div', null, React.createElement(Kiddo, null, 'Should have data-override')); + } + }; + + const html = renderToString(React.createElement(Box.Provider, { value }, React.createElement(Parent))); + + assume(html).equals('

Should have data-override

'); + }); }); describe('lock-based override detection', function lockBasedOverrides() { - it('does not flag className when environment is locked and slot is from same generation', function lockedSameGen() { + it('flags className when passed to component inside locked environment', function lockedSameGen() { const TestComponent = withSlots('LockSameGen', function Component(args: any) { const { props } = useProps(args); return React.createElement('div', props); @@ -150,9 +277,8 @@ describe('@bento/slots override', function bento() { }) ); - // Should NOT have data-override since it's internal composition (same generation) - assume(html).contains('
'); - assume(html).does.not.contain('data-override'); + // SHOULD have data-override since className is passed from outside the lock + assume(html).contains('
'); }); it('flags className when environment is locked and slot is from earlier generation', function lockedEarlierGen() { @@ -230,7 +356,9 @@ describe('@bento/slots override', function bento() { }) ); - assume(html).contains('
'); + // Without lock, no data-override should be added + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); }); it('flags style modifications from earlier generation when locked', function lockedStyleEarlier() { diff --git a/packages/slots/test/replace.node.test.ts b/packages/slots/test/replace.node.test.ts index b17023f95..d834d7db2 100644 --- a/packages/slots/test/replace.node.test.ts +++ b/packages/slots/test/replace.node.test.ts @@ -3,89 +3,206 @@ import { describe, it } from 'vitest'; import assume from 'assume'; import { renderToString } from 'react-dom/server'; import { withSlots } from '@bento/slots'; -import { Box, defaults } from '@bento/box'; +import { Environment } from '@bento/environment'; import React from 'react'; describe('@bento/slots replace', function bento() { - function createComponent(name: string, props = {}, slots = {}, components = {}) { + function renderWithEnvironment( + name: string, + props: Record, + components: Record, + lock: boolean + ) { const TestReturn = withSlots(`BentoReplace-${name}`, function Component(args: any) { return React.createElement('div', { ...args }); }); - const value = defaults(); - value.slots.assigned = { test: slots }; - value.env.components = components; + const child = React.createElement(TestReturn, { slot: 'test', ...props }); - return renderToString( - React.createElement(Box.Provider, { value }, React.createElement(TestReturn, { slot: 'test', ...props })) - ); + return renderToString(React.createElement(Environment, { lock, components, children: child })); + } + + function renderWithoutEnvironment(name: string, props: Record) { + const TestReturn = withSlots(`BentoReplace-${name}`, function Component(args: any) { + return React.createElement('div', { ...args }); + }); + + return renderToString(React.createElement(TestReturn, { slot: 'test', ...props })); } it('exposes the function', function exposes() { assume(replace).is.a('function'); }); - it('introduces data-override with context when component is replaced', function componentReplacement() { - const html = createComponent( - 'componentReplacement', - { id: 'example' }, - {}, - { - 'BentoReplace-componentReplacement': function Component(props: any) { - assume(props['data-override']).equals('context'); - return React.createElement('div', props); - } - } - ); - - assume(html).contains('
'); + describe('default behavior (no Environment component)', function defaultBehavior() { + it('renders without data-override attributes by default', function noDataOverride() { + const html = renderWithoutEnvironment('noDataOverride', { id: 'example', className: 'test-class' }); + + assume(html).contains(''); + assume(html).does.not.contain('data-override'); + }); + + it('flags context with data-override when Environment is locked', function lockedWithOverride() { + const html = renderWithEnvironment( + 'componentReplacement-locked', + { id: 'example' }, + { + 'BentoReplace-componentReplacement-locked': function Component(props: any) { + assume(props['data-override']).equals('context'); + return React.createElement('div', props); + } + }, + true + ); + + assume(html).contains('
'); + }); }); - it('allows props to be overridden through component replacement', function propsOverride() { - const html = createComponent( - 'propsOverride', - { id: 'original' }, - {}, - { - 'BentoReplace-propsOverride': { - props: { - id: 'overridden', - className: 'custom-class' + describe('props-based overrides', function propsOverride() { + it('renders props override without data-override when unlocked', function unlockedPropsOverride() { + const html = renderWithEnvironment( + 'propsOverride-unlocked', + { id: 'original' }, + { + 'BentoReplace-propsOverride-unlocked': { + props: { + id: 'overridden', + className: 'custom-class' + } + } + }, + false + ); + + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); + + it('flags context and className with data-override when locked', function lockedPropsOverride() { + const html = renderWithEnvironment( + 'propsOverride-locked', + { id: 'original' }, + { + 'BentoReplace-propsOverride-locked': { + props: { + id: 'overridden', + className: 'custom-class' + } } - } - } - ); + }, + true + ); - assume(html).contains('
'); + assume(html).contains('
'); + }); }); - it('combines data-override values when component has existing overrides', function combinedOverrides() { - const html = createComponent( - 'combinedOverrides', - { style: { color: 'red' } }, - {}, - { - 'BentoReplace-combinedOverrides': function Component(props: any) { - assume(props['data-override']).equals('context style'); - return React.createElement('div', props); - } - } - ); - - assume(html).contains('
'); + describe('merging data-override values', function combinedOverrides() { + it('renders without data-override when unlocked', function unlockedNoMerge() { + const html = renderWithEnvironment( + 'combinedOverrides-unlocked', + { style: { color: 'red' } }, + { + 'BentoReplace-combinedOverrides-unlocked': function Component(props: any) { + assume(props['data-override']).equals(undefined); + return React.createElement('div', props); + } + }, + false + ); + + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); + + it('merges context and style into data-override when locked', function lockedMerge() { + const html = renderWithEnvironment( + 'combinedOverrides-locked', + { style: { color: 'red' } }, + { + 'BentoReplace-combinedOverrides-locked': function Component(props: any) { + assume(props['data-override']).equals('context style'); + return React.createElement('div', props); + } + }, + true + ); + + assume(html).contains('
'); + }); }); - it('handles falsy target components gracefully', function falsyTarget() { - const html = createComponent( - 'falsyTarget', - { id: 'example' }, - {}, - { - 'BentoReplace-falsyTarget': null - } - ); - - // Should render the original component without any replacement - assume(html).contains('
'); + describe('edge cases', function edgeCases() { + it('handles falsy target components without data-override when unlocked', function falsyTargetUnlocked() { + const html = renderWithEnvironment( + 'falsyTarget-unlocked', + { id: 'example' }, + { + 'BentoReplace-falsyTarget-unlocked': null + }, + false + ); + + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); + + it('handles falsy target components without data-override when locked', function falsyTargetLocked() { + const html = renderWithEnvironment( + 'falsyTarget-locked', + { id: 'example' }, + { + 'BentoReplace-falsyTarget-locked': null + }, + true + ); + + assume(html).contains('
'); + assume(html).does.not.contain('data-override'); + }); }); }); From c8749531ff3f214ef53e27fb44fa5d6e668073c8 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Fri, 14 Nov 2025 20:41:15 +0100 Subject: [PATCH 14/19] fix: update tests as data-override is no longer triggering by default --- packages/button/test/button.node.test.tsx | 17 ------ .../container/test/container.browser.test.tsx | 11 ---- packages/divider/test/divider.node.test.tsx | 9 ---- .../divider/test/examples.browser.test.tsx | 1 - .../test/examples.browser.test.tsx | 6 +-- packages/heading/test/heading.node.test.tsx | 14 ----- .../pressable/test/pressable.node.test.tsx | 18 ------- packages/slots/src/override.ts | 6 ++- packages/slots/src/replace.ts | 21 ++++++-- packages/slots/src/slots.tsx | 13 +++-- packages/slots/test/replace.node.test.ts | 1 + packages/slots/test/slots.node.test.ts | 52 +++++++++---------- packages/text/test/text.node.test.tsx | 12 ----- 13 files changed, 58 insertions(+), 123 deletions(-) diff --git a/packages/button/test/button.node.test.tsx b/packages/button/test/button.node.test.tsx index 0f0797bb9..8aa104955 100644 --- a/packages/button/test/button.node.test.tsx +++ b/packages/button/test/button.node.test.tsx @@ -31,22 +31,6 @@ describe('@bento/button', function bento() { }); describe('#slots', function slots() { - it('renders correct [data-override] attribute values', function overrides() { - const result = renderToStringButton({ - className: 'custom-class', - style: { color: 'red' }, - children:
Press me
, - onClick: () => void 0, - onPress: () => void 0, - onPressStart: () => void 0, - onPressEnd: () => void 0, - onPressUp: () => void 0, - onPressChange: () => void 0 - }); - - assume(result).includes('data-override="className style"'); - }); - it('introduced the `pressable` slot to the Pressable component', function pressable() { const result = renderToStringButton({ children:
Press me
, @@ -57,7 +41,6 @@ describe('@bento/button', function bento() { } }); - assume(result).includes('data-override="slot"'); assume(result).includes('data-foo="bar"'); }); }); diff --git a/packages/container/test/container.browser.test.tsx b/packages/container/test/container.browser.test.tsx index 523fe428e..633170929 100644 --- a/packages/container/test/container.browser.test.tsx +++ b/packages/container/test/container.browser.test.tsx @@ -93,17 +93,6 @@ describe('@bento/container', function bento() { expect(element?.children[1].textContent).toBe('Second'); }); - it('tracks overrides via data-override attribute', function tracksOverrides() { - const { container } = render( - - Overridden - - ); - const element = container.firstChild as HTMLElement; - - expect(element?.getAttribute('data-override')).toBeTruthy(); - }); - it('renders with data attributes', function rendersDataAttributes() { const { container } = render( diff --git a/packages/divider/test/divider.node.test.tsx b/packages/divider/test/divider.node.test.tsx index efa83d54b..bce540806 100644 --- a/packages/divider/test/divider.node.test.tsx +++ b/packages/divider/test/divider.node.test.tsx @@ -46,15 +46,6 @@ describe('@bento/divider', function bento() { assume(result).doesnt.include(' orientation='); }); - it('renders correct [data-override] attribute values when directly overridden', function overrides() { - const result = renderToStringDivider({ - className: 'custom-class', - style: { color: 'red' } - }); - - assume(result).includes('data-override="className style"'); - }); - it('allows user to override classname fully', function classname() { const result = renderToStringDivider({ className: 'custom-class' diff --git a/packages/divider/test/examples.browser.test.tsx b/packages/divider/test/examples.browser.test.tsx index ad6a18fac..2568d183e 100644 --- a/packages/divider/test/examples.browser.test.tsx +++ b/packages/divider/test/examples.browser.test.tsx @@ -36,7 +36,6 @@ describe('@bento/divider examples', function bento() { expect(divider).toHaveAttribute('style'); expect(divider).toHaveStyle('height: 100px'); - expect(divider).toHaveAttribute('data-override', 'style'); }); it('renders a vertical divider in a div', function verticalInDivTest() { diff --git a/packages/environment/test/examples.browser.test.tsx b/packages/environment/test/examples.browser.test.tsx index 0a1b8ea91..acb7de975 100644 --- a/packages/environment/test/examples.browser.test.tsx +++ b/packages/environment/test/examples.browser.test.tsx @@ -18,7 +18,7 @@ describe('@bento/environment examples', function bento() { const result = container.innerHTML; assume(result).equals( - '' + '' ); }); }); @@ -30,7 +30,7 @@ describe('@bento/environment examples', function bento() { const result = container.innerHTML; assume(result).equals( - '
' + '
' ); }); }); @@ -40,7 +40,7 @@ describe('@bento/environment examples', function bento() { const { container } = await render(); const result = container.innerHTML; assume(result).to.equal( - '

This text will be rendered with default styling

' + '

This text will be rendered with default styling

' ); }); diff --git a/packages/heading/test/heading.node.test.tsx b/packages/heading/test/heading.node.test.tsx index 163d7ae09..990197f2c 100644 --- a/packages/heading/test/heading.node.test.tsx +++ b/packages/heading/test/heading.node.test.tsx @@ -62,20 +62,6 @@ describe('@bento/heading', function bento() { assume(result).match(/^]*>Handgloves<\/span>$/); }); - describe('#slots', function slots() { - it('renders correct [data-override] attribute values', function dataOverride() { - const result = renderToStringHeading({ - children: 'Handgloves', - className: 'custom-class', - style: { - color: 'red' - } - }); - - assume(result).includes('data-override="className style"'); - }); - }); - describe('Public API', function packageSuite() { it('has HeadingProvider attached', function hasProvider() { assume(HeadingProvider).to.be.an('object'); diff --git a/packages/pressable/test/pressable.node.test.tsx b/packages/pressable/test/pressable.node.test.tsx index 7d082d5b7..b968cc5e4 100644 --- a/packages/pressable/test/pressable.node.test.tsx +++ b/packages/pressable/test/pressable.node.test.tsx @@ -29,24 +29,6 @@ describe('@bento/pressable', function bento() { assume(result).match(/^]*>Press me<\/div>$/); }); - describe('#slots', function slots() { - it('renders correct [data-override] attribute values', function overrides() { - const result = renderToStringPressable({ - className: 'custom-class', - style: { color: 'red' }, - children:
Press me
, - onClick: () => void 0, - onPress: () => void 0, - onPressStart: () => void 0, - onPressEnd: () => void 0, - onPressUp: () => void 0, - onPressChange: () => void 0 - }); - - assume(result).includes('data-override="className style"'); - }); - }); - describe('Public API', function packageSuite() { const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/slots/src/override.ts b/packages/slots/src/override.ts index 58db37959..9bb1cebd8 100644 --- a/packages/slots/src/override.ts +++ b/packages/slots/src/override.ts @@ -72,7 +72,11 @@ export function override>({ if (!(context.env?.locked ?? false)) return; const currentLockGeneration = context.env?.lockGeneration ?? 0; - const slotGeneration = context.slots?.slotGenerations?.[currentNamespace] ?? currentLockGeneration; + // + // Default to generation 0 (before any locks) if no slot generation is tracked + // This ensures props are flagged as overrides unless explicitly tracked at current generation + // + const slotGeneration = context.slots?.slotGenerations?.[currentNamespace] ?? 0; if (typeof props['data-override'] === 'string') { causes.push(...props['data-override'].split(' ')); diff --git a/packages/slots/src/replace.ts b/packages/slots/src/replace.ts index b0d9ea278..004e4bccf 100644 --- a/packages/slots/src/replace.ts +++ b/packages/slots/src/replace.ts @@ -64,15 +64,28 @@ export function replace>({ props, name, contex override: !!target } }, - props: {} + props: undefined as Record | undefined, + Component: undefined as ComponentType | undefined } as any; + if (isPropsOverride(target)) result.props = { ...target.props }; + else result.Component = target; + + const locked = context.env?.locked ?? false; + if (!locked) return result; + const causes = (props['data-override'] || '').split(' ').filter(Boolean); if (!causes.includes('context')) causes.push('context'); - result.props = useDataAttributes({ override: causes }); - if (isPropsOverride(target)) result.props = { ...target.props }; - else result.Component = target; + const overrideProps = useDataAttributes({ override: causes }); + if (isPropsOverride(target)) { + result.props = { + ...overrideProps, + ...result.props + }; + } else { + result.props = overrideProps; + } return result; } diff --git a/packages/slots/src/slots.tsx b/packages/slots/src/slots.tsx index 6580c4a0a..51b933faa 100644 --- a/packages/slots/src/slots.tsx +++ b/packages/slots/src/slots.tsx @@ -101,6 +101,9 @@ export function withSlots( // parent component slots should take precedence over child ones. // const currentGeneration = ctx.env.lockGeneration || 0; + // Slots passed via props come from the parent context, so they should be + // tagged with the generation before the current component if inside a lock + const slotPropsGeneration = currentGeneration > 0 ? currentGeneration - 1 : 0; for (const slotKey in slots) { // Build the fully qualified slot key by prefixing with current namespace @@ -114,14 +117,14 @@ export function withSlots( // if (!assignedSlot) { ctx.slots.assigned[namespacedKey] = newSlot; - // Tag new slot with current generation - ctx.slots.slotGenerations[namespacedKey] = currentGeneration; + // Tag new slot with generation from props (before current component) + ctx.slots.slotGenerations[namespacedKey] = slotPropsGeneration; } else if (typeof assignedSlot === 'object') { ctx.slots.assigned[namespacedKey] = { ...newSlot, ...assignedSlot }; // Keep the earliest (lowest) generation when merging - // If the slot doesn't have a generation yet, use current generation + // If the slot doesn't have a generation yet, use props generation if (!(namespacedKey in ctx.slots.slotGenerations)) { - ctx.slots.slotGenerations[namespacedKey] = currentGeneration; + ctx.slots.slotGenerations[namespacedKey] = slotPropsGeneration; } // If newSlot is from an earlier generation (consumer slot), update the tag // We assume parent slots (assignedSlot) are from consumer, so keep their generation @@ -137,7 +140,7 @@ export function withSlots( // For functions, keep the parent function's generation (it takes precedence) ctx.slots.slotGenerations = ctx.slots.slotGenerations || {}; if (!(namespacedKey in ctx.slots.slotGenerations)) { - ctx.slots.slotGenerations[namespacedKey] = currentGeneration; + ctx.slots.slotGenerations[namespacedKey] = slotPropsGeneration; } } } diff --git a/packages/slots/test/replace.node.test.ts b/packages/slots/test/replace.node.test.ts index d834d7db2..dbe1b357f 100644 --- a/packages/slots/test/replace.node.test.ts +++ b/packages/slots/test/replace.node.test.ts @@ -137,6 +137,7 @@ describe('@bento/slots replace', function bento() { true ); + // Context and className should both be flagged assume(html).contains('
'); }); }); diff --git a/packages/slots/test/slots.node.test.ts b/packages/slots/test/slots.node.test.ts index 7debc2688..c8e97e971 100644 --- a/packages/slots/test/slots.node.test.ts +++ b/packages/slots/test/slots.node.test.ts @@ -7,6 +7,7 @@ import { Nested } from '../examples/nested.tsx'; import { Box, defaults } from '@bento/box'; import { Environment } from '@bento/environment'; import { fileURLToPath } from 'node:url'; +import { useProps } from '@bento/use-props'; import fs from 'node:fs/promises'; import assume from 'assume'; import React from 'react'; @@ -93,21 +94,18 @@ describe('@bento/slots', function bento() { let id: string | undefined; const nested = renderToString( - React.createElement( - Environment, - { - components: { - SlotsButton: function Button(props: any) { - assume(props['data-override']).is.undefined(); - assume(props.id).startsWith(':R'); - id = props.id; - - return React.createElement('p', props, 'No more button, only text'); - } + React.createElement(Environment, { + components: { + SlotsButton: function Button(props: any) { + assume(props['data-override']).equals(undefined); + assume(props.id).startsWith(':R'); + id = props.id; + + return React.createElement('p', props, 'No more button, only text'); } }, - React.createElement(Nested) - ) + children: React.createElement(Nested) + }) ); assume(nested).contains('Hello World'); @@ -119,22 +117,19 @@ describe('@bento/slots', function bento() { let id: string | undefined; const nested = renderToString( - React.createElement( - Environment, - { - lock: true, - components: { - SlotsButton: function Button(props: any) { - assume(props['data-override']).equals('context'); - assume(props.id).startsWith(':R'); - id = props.id; - - return React.createElement('p', props, 'No more button, only text'); - } + React.createElement(Environment, { + lock: true, + components: { + SlotsButton: function Button(props: any) { + assume(props['data-override']).equals('context'); + assume(props.id).startsWith(':R'); + id = props.id; + + return React.createElement('p', props, 'No more button, only text'); } }, - React.createElement(Nested) - ) + children: React.createElement(Nested) + }) ); assume(nested).contains('Hello World'); @@ -154,7 +149,8 @@ describe('@bento/slots', function bento() { }); it('slots are correctly applied and tracked when locked', function applicationLocked() { - const Child = withSlots('TrackChild', function TrackChildComponent(props: any) { + const Child = withSlots('TrackChild', function TrackChildComponent(args: any) { + const { props } = useProps(args); return React.createElement('span', props, 'child'); }); diff --git a/packages/text/test/text.node.test.tsx b/packages/text/test/text.node.test.tsx index 0306b54f7..185206e5c 100644 --- a/packages/text/test/text.node.test.tsx +++ b/packages/text/test/text.node.test.tsx @@ -78,18 +78,6 @@ describe('@bento/text', function bento() { assume(result).includes('--wrap:pretty'); }); - describe('#slots', function slots() { - it('renders correct [data-override] attribute values', function dataOverride() { - const result = renderToStringText({ - className: 'custom-class', - style: { color: 'red' }, - children: 'Handgloves' - }); - - assume(result).includes('data-override="className style"'); - }); - }); - describe('Public API', function packageSuite() { const __dirname = dirname(fileURLToPath(import.meta.url)); From 09861c40193ebf70c619b9e7f35d22557eee2d07 Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Fri, 19 Dec 2025 01:10:00 +0100 Subject: [PATCH 15/19] refactor: convert browser tests to use vitest snapshots - Convert use-props, slots, and environment browser tests to use toMatchSnapshot() - Add withLock HOC for wrapping components with locked Environment - Fix use-props node tests to use locked environment when testing data-override - Add @bento/environment and @bento/use-props as devDependencies for slots tests --- packages/environment/examples/with-lock.tsx | 96 ++++++++++++++++ packages/environment/src/index.tsx | 29 ++++- packages/environment/src/with-lock.tsx | 60 ++++++++++ .../examples.browser.test.tsx.snap | 7 ++ .../test/examples.browser.test.tsx | 89 ++++++++------- packages/slots/package.json | 4 +- packages/slots/src/override.ts | 59 +++++----- packages/slots/src/replace.ts | 16 +++ packages/slots/src/slots.tsx | 13 ++- .../examples.browser.test.tsx.snap | 21 ++++ packages/slots/test/examples.browser.test.tsx | 68 ++---------- packages/slots/test/override.node.test.ts | 105 ++++++++++++------ packages/slots/test/replace.node.test.ts | 23 ++-- packages/slots/test/slots.node.test.ts | 17 ++- packages/slots/test/slots.node.test.tsx | 4 + .../examples.browser.test.tsx.snap | 17 +++ .../use-props/test/examples.browser.test.tsx | 57 ++-------- .../use-props/test/use-props.node.test.tsx | 16 ++- 18 files changed, 468 insertions(+), 233 deletions(-) create mode 100644 packages/environment/examples/with-lock.tsx create mode 100644 packages/environment/src/with-lock.tsx create mode 100644 packages/environment/test/__snapshots__/examples.browser.test.tsx.snap create mode 100644 packages/slots/test/__snapshots__/examples.browser.test.tsx.snap create mode 100644 packages/use-props/test/__snapshots__/examples.browser.test.tsx.snap diff --git a/packages/environment/examples/with-lock.tsx b/packages/environment/examples/with-lock.tsx new file mode 100644 index 000000000..7e07090f3 --- /dev/null +++ b/packages/environment/examples/with-lock.tsx @@ -0,0 +1,96 @@ +import { withLock } from '@bento/environment'; +import { withSlots } from '@bento/slots'; +import { useProps } from '@bento/use-props'; +import { Button } from '@bento/button'; +import { Container } from '@bento/container'; +import { RadioGroup, Radio } from '@bento/radio'; +/* v8 ignore next */ +import React from 'react'; + +/** + * Interface for component props. + * + * @interface ComponentProps + * @public + */ +interface ComponentProps { + [key: string]: any; +} + +/** + * Internal composed component with slots. + * This is the "raw" component that will be wrapped by the design system. + * + * @param {ComponentProps} props - The component props. + * @returns {JSX.Element} The rendered composed component. + * @public + */ +const Composed = withSlots('WithLock.Composed', function ComposedComponent(props: ComponentProps) { + const slots = { + 'group.label': { className: 'label' }, + 'group.description': { className: 'describe' } + }; + + return ( + + + + Apple + Banana + Orange + + + ); +}); + +/** + * Design system component created using withLock HOC. + * The lock boundary is automatically created by withLock. + * + * @public + */ +const DesignSystemComponent = withSlots( + 'WithLock.DSComponent', + withLock(function DSComponentInner(props: ComponentProps) { + const { props: p } = useProps(props); + const slots = { + 'root.trigger': { + children: 'Click Me' + } + }; + + return ; + }) +); + +/** + * Example demonstrating withLock with NO consumer overrides. + * Expected: NO data-override attributes anywhere. + * All slots (trigger, label, description) are internal composition. + * + * @returns {JSX.Element} The rendered example. + * @public + */ +export const WithLockNoOverride: React.FC = function WithLockNoOverride() { + return ; +}; + +/** + * Example demonstrating withLock WITH consumer overrides. + * Expected: data-override on trigger button (consumer changed children). + * Label and description should NOT have data-override (internal composition). + * + * @returns {JSX.Element} The rendered example. + * @public + */ +export const WithLockWithOverride: React.FC = function WithLockWithOverride() { + return ( + + ); +}; diff --git a/packages/environment/src/index.tsx b/packages/environment/src/index.tsx index 901d53782..69da3da2e 100644 --- a/packages/environment/src/index.tsx +++ b/packages/environment/src/index.tsx @@ -90,14 +90,37 @@ export function Environment({ children, lock = false, ...config }: EnvironmentPr const { root, ...options } = config; ctx.env = merge(ctx.env, options) as EnvContext>; + ctx.slots = { ...ctx.slots }; // Handle lock generation if (lock && !ctx.env.locked) { - // First lock in the tree - set locked and increment generation + // First lock in the tree + // IMPORTANT: Before incrementing generation, tag ALL existing slots + // with the current (pre-lock) generation. This marks them as "consumer slots" + // that were passed before the lock boundary. + const preLockGeneration = ctx.env.lockGeneration; + ctx.slots.slotGenerations = { ...ctx.slots.slotGenerations }; + + for (const slotKey in ctx.slots.assigned) { + if (!(slotKey in ctx.slots.slotGenerations)) { + ctx.slots.slotGenerations[slotKey] = preLockGeneration; + } + } + + // Now set locked and increment generation ctx.env.locked = true; ctx.env.lockGeneration = ctx.env.lockGeneration + 1; } else if (lock && ctx.env.locked) { - // Nested lock - increment generation + // Nested lock - tag existing slots and increment generation + const preLockGeneration = ctx.env.lockGeneration; + ctx.slots.slotGenerations = { ...ctx.slots.slotGenerations }; + + for (const slotKey in ctx.slots.assigned) { + if (!(slotKey in ctx.slots.slotGenerations)) { + ctx.slots.slotGenerations[slotKey] = preLockGeneration; + } + } + ctx.env.lockGeneration = ctx.env.lockGeneration + 1; } // If lock={false}, don't increment generation (inherit parent's generation) @@ -114,3 +137,5 @@ export function Environment({ children, lock = false, ...config }: EnvironmentPr return {children}; } + +export { withLock } from './with-lock.tsx'; diff --git a/packages/environment/src/with-lock.tsx b/packages/environment/src/with-lock.tsx new file mode 100644 index 000000000..363d190ea --- /dev/null +++ b/packages/environment/src/with-lock.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Environment, type EnvironmentProps } from './index.tsx'; + +/** + * Higher-order component that wraps a component with a locked Environment. + * This creates a "lock boundary" that flags any slot modifications applied + * from outside the lock as consumer overrides (with data-override attributes). + * + * This is useful for design system components that want to distinguish between: + * - Internal composition (slots added inside the locked component) - not flagged + * - Consumer modifications (slots passed from outside) - flagged with data-override + * + * @param Component - The component to wrap with a locked environment + * @param displayName - Optional display name for the wrapped component + * @returns A new component wrapped with Environment lock={true} + * + * @example + * ```tsx + * // Create a design system component with lock boundary + * const MyDesignComponent = withLock(function MyComponent(props) { + * return ( + * + * + * + * ); + * }); + * + * // Consumer usage - any slots passed here will be flagged + * + * // The trigger button will have data-override="className slot" + * ``` + */ +export function withLock( + Component: React.ComponentType, + displayName?: string +): React.ComponentType> { + function LockedComponent(props: Props & Partial) { + const { components, window, document, sprite, root, ...componentProps } = props as Props & + Partial; + + const envProps: Partial = { + lock: true, + ...(components && { components }), + ...(window && { window }), + ...(document && { document }), + ...(sprite && { sprite }), + ...(root && { root }) + }; + + return ( + + + + ); + } + + LockedComponent.displayName = displayName || `withLock(${Component.displayName || Component.name || 'Component'})`; + + return LockedComponent; +} diff --git a/packages/environment/test/__snapshots__/examples.browser.test.tsx.snap b/packages/environment/test/__snapshots__/examples.browser.test.tsx.snap new file mode 100644 index 000000000..1ba16f324 --- /dev/null +++ b/packages/environment/test/__snapshots__/examples.browser.test.tsx.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`@bento/environment examples > CustomButtonExample > should render the custom button component 1`] = `"

This text will be rendered with default styling

"`; + +exports[`@bento/environment examples > Override > should render the Container component 1`] = `""`; + +exports[`@bento/environment examples > OverrideProps > should render the Container component 1`] = `"
"`; diff --git a/packages/environment/test/examples.browser.test.tsx b/packages/environment/test/examples.browser.test.tsx index acb7de975..7c32ace5a 100644 --- a/packages/environment/test/examples.browser.test.tsx +++ b/packages/environment/test/examples.browser.test.tsx @@ -6,7 +6,7 @@ import { Override } from '../examples/override.tsx'; import { LockNoOverride } from '../examples/lock-no-override.tsx'; import { LockWithOverride } from '../examples/lock-with-override.tsx'; import { render } from 'vitest-browser-react'; -import { describe, it } from 'vitest'; +import { describe, it, expect } from 'vitest'; import assume from 'assume'; import React from 'react'; @@ -14,34 +14,21 @@ describe('@bento/environment examples', function bento() { describe('Override', function container() { it('should render the Container component', function test() { const { container } = render(); - - const result = container.innerHTML; - - assume(result).equals( - '' - ); + expect(container.innerHTML).toMatchSnapshot(); }); }); describe('OverrideProps', function container() { it('should render the Container component', function test() { const { container } = render(); - - const result = container.innerHTML; - - assume(result).equals( - '
' - ); + expect(container.innerHTML).toMatchSnapshot(); }); }); describe('CustomButtonExample', function container() { it('should render the custom button component', async function testCustomButtonRender() { const { container } = await render(); - const result = container.innerHTML; - assume(result).to.equal( - '

This text will be rendered with default styling

' - ); + expect(container.innerHTML).toMatchSnapshot(); }); it('should not render the BentoButton', async function testBentoButtonNotRendered() { @@ -187,9 +174,7 @@ describe('@bento/environment examples', function bento() { const result = container.innerHTML; // Should have NO data-override attributes since all slots are internal composition - assume(result).equals( - '
Favorite fruit
Pick your favorite
' - ); + assume(result).does.not.include('data-override'); }); it('should render the button with internal composition text', function test() { @@ -207,26 +192,37 @@ describe('@bento/environment examples', function bento() { assume(radioInputs.length).equals(3); }); - it('should have label and description with internal composition classes', function test() { + it('should have data-slot attributes on slotted components', function test() { const { container } = render(); - // Check for label element - const label = container.querySelector('.label'); - assume(label).to.not.equal(null); + // Container should have data-slot="root" + const rootSlot = container.querySelector('[data-slot="root"]'); + assume(rootSlot).to.not.equal(null); + + // Button should have data-slot="pressable" + const buttonSlot = container.querySelector('[data-slot="pressable"]'); + assume(buttonSlot).to.not.equal(null); + }); + + it('should have label and description on radio group', function test() { + const { container } = render(); - // Check for description element - const description = container.querySelector('.describe'); - assume(description).to.not.equal(null); + // Check for radio group with label attribute + const radioGroup = container.querySelector('[role="radiogroup"]'); + assume(radioGroup).to.not.equal(null); + assume(radioGroup?.getAttribute('label')).equals('Favorite fruit'); + assume(radioGroup?.getAttribute('description')).equals('Pick your favorite'); }); }); describe('LockWithOverride', function lockWithOverride() { it('should render with data-override only on the trigger button', function test() { const { container } = render(); + const button = container.querySelector('button'); - assume(container.innerHTML).equals( - '
Favorite fruit
Pick your favorite
' - ); + // Button should have data-override="slot" because consumer modified it + assume(button).to.not.equal(null); + assume(button?.getAttribute('data-override')).equals('slot'); }); it('should render the button with consumer override text', function test() { @@ -237,16 +233,31 @@ describe('@bento/environment examples', function bento() { assume(button?.textContent).equals('Hello World'); }); - it('should not flag internal label and description with data-override', function test() { + it('should have data-slot on slotted components', function test() { + const { container } = render(); + + // Button should have data-slot="pressable" + const buttonSlot = container.querySelector('[data-slot="pressable"]'); + assume(buttonSlot).to.not.equal(null); + + // RadioGroup icons should have data-slot="content" + const iconSlots = container.querySelectorAll('[data-slot="content"]'); + assume(iconSlots.length).equals(3); + }); + + it('should not flag internal composition with data-override', function test() { const { container } = render(); - const label = container.querySelector('.label'); - const description = container.querySelector('.describe'); - - // Internal composition should not be flagged with data-override - assume(label).to.not.equal(null); - assume(description).to.not.equal(null); - assume(label?.hasAttribute('data-override')).equals(false); - assume(description?.hasAttribute('data-override')).equals(false); + + // RadioGroup should not have data-override + const radioGroup = container.querySelector('[role="radiogroup"]'); + assume(radioGroup).to.not.equal(null); + assume(radioGroup?.hasAttribute('data-override')).equals(false); + + // Icons should not have data-override + const icons = container.querySelectorAll('svg'); + icons.forEach(function checkIcon(icon) { + assume(icon.hasAttribute('data-override')).equals(false); + }); }); it('should only have data-override on consumer-modified slots', function test() { diff --git a/packages/slots/package.json b/packages/slots/package.json index 7ae9549a8..db69f70de 100644 --- a/packages/slots/package.json +++ b/packages/slots/package.json @@ -48,8 +48,8 @@ "use-deep-compare": "^1.3.0" }, "devDependencies": { - "@bento/environment": "*", - "@bento/use-props": "*" + "@bento/environment": "^0.1.4", + "@bento/use-props": "^0.2.2" }, "peerDependencies": { "react": "18.x || 19.x", diff --git a/packages/slots/src/override.ts b/packages/slots/src/override.ts index 9bb1cebd8..240856c11 100644 --- a/packages/slots/src/override.ts +++ b/packages/slots/src/override.ts @@ -45,8 +45,6 @@ interface OverrideResult { }; } -const triggers: string[] = ['className', 'style']; - /** * Overrides the properties of a given context based on certain conditions. * When the environment is locked, only flags slots that were added before @@ -73,10 +71,10 @@ export function override>({ const currentLockGeneration = context.env?.lockGeneration ?? 0; // - // Default to generation 0 (before any locks) if no slot generation is tracked - // This ensures props are flagged as overrides unless explicitly tracked at current generation + // Get slot generation for this namespace. Default to current generation if not tracked. // - const slotGeneration = context.slots?.slotGenerations?.[currentNamespace] ?? 0; + const slotGeneration = context.slots?.slotGenerations?.[currentNamespace] ?? currentLockGeneration; + const isEarlierGeneration = slotGeneration < currentLockGeneration; if (typeof props['data-override'] === 'string') { causes.push(...props['data-override'].split(' ')); @@ -84,37 +82,40 @@ export function override>({ if (overrideFlag && !causes.includes('context')) causes.push('context'); - // Only flag className if slot is from an earlier generation - if ('className' in props && !causes.includes('className') && slotGeneration < currentLockGeneration) { - causes.push('className'); - } - // - // For style we need to take a more sophisticated approach, users are allowed - // to define CSS variables in the style prop, so we need to check if the keys - // are prefixed with `--` or not. + // Only flag className/style if they came from a slot assignment (exist in the slot object) + // AND the slot is from an earlier generation. Props that are only in `props` (not from slots) + // might be from the component's own render (e.g., CSS modules) and shouldn't be flagged. // - if ('style' in props && !causes.includes('style')) { - const style = props.style as CSSProperties; - const keys = Object.keys(style); + if (slot && isEarlierGeneration) { + let hasFlaggableChanges = false; - if (keys.some((key) => !isCSSVariable(key)) && slotGeneration < currentLockGeneration) { - causes.push('style'); + if ('className' in slot && !causes.includes('className')) { + causes.push('className'); + hasFlaggableChanges = true; } - } - // Only flag slot modifications if the slot's generation is less than the current lock generation - if (slot && slotGeneration < currentLockGeneration) { - // Any slot modification from an earlier generation should be flagged - if (!causes.includes('slot')) { + if ('style' in slot && !causes.includes('style')) { + const style = slot.style as CSSProperties; + const keys = Object.keys(style); + const hasNonCSSVariables = keys.some((key) => !isCSSVariable(key)); + + if (hasNonCSSVariables) { + causes.push('style'); + hasFlaggableChanges = true; + } + } + + // Check for other slot modifications (not className/style/children which are handled separately) + const slotKeys = Object.keys(slot).filter((key) => !['className', 'style'].includes(key)); + if (slotKeys.length > 0) { + hasFlaggableChanges = true; + } + + // Only add 'slot' if there are actual modifications worth flagging + if (hasFlaggableChanges && !causes.includes('slot')) { causes.push('slot'); } - // Also add specific triggers if present - Object.keys(slot).forEach(function forEach(name) { - if (triggers.includes(name) && !causes.includes(name)) { - causes.push(name); - } - }); } if (!causes.length) return; diff --git a/packages/slots/src/replace.ts b/packages/slots/src/replace.ts index 004e4bccf..e91c1ef42 100644 --- a/packages/slots/src/replace.ts +++ b/packages/slots/src/replace.ts @@ -77,6 +77,22 @@ export function replace>({ props, name, contex const causes = (props['data-override'] || '').split(' ').filter(Boolean); if (!causes.includes('context')) causes.push('context'); + // Merge props from original and override target for override detection + const mergedProps = isPropsOverride(target) ? { ...props, ...target.props } : props; + + // Check for className and style overrides in the merged props + if ('className' in mergedProps && !causes.includes('className')) { + causes.push('className'); + } + if ('style' in mergedProps && !causes.includes('style')) { + // Check if style contains non-CSS-variable properties + const styleKeys = Object.keys(mergedProps.style || {}); + const hasNonCSSVariables = styleKeys.some((key) => !key.startsWith('--')); + if (hasNonCSSVariables) { + causes.push('style'); + } + } + const overrideProps = useDataAttributes({ override: causes }); if (isPropsOverride(target)) { result.props = { diff --git a/packages/slots/src/slots.tsx b/packages/slots/src/slots.tsx index f94fd46e1..e046cc1f3 100644 --- a/packages/slots/src/slots.tsx +++ b/packages/slots/src/slots.tsx @@ -111,9 +111,16 @@ export function withSlots( // parent component slots should take precedence over child ones. // const currentGeneration = ctx.env.lockGeneration || 0; - // Slots passed via props come from the parent context, so they should be - // tagged with the generation before the current component if inside a lock - const slotPropsGeneration = currentGeneration > 0 ? currentGeneration - 1 : 0; + // + // Slots passed via props at this point are part of the CURRENT render tree. + // If we're inside a locked environment, these are "internal composition" slots + // and should be tagged with the CURRENT generation (not flagged as overrides). + // + // Consumer slots (passed from OUTSIDE the lock) are tagged by the Environment + // component BEFORE it increments the generation, so they have a lower generation + // and will be flagged as overrides. + // + const slotPropsGeneration = currentGeneration; for (const slotKey in slots) { // Build the fully qualified slot key by prefixing with current namespace diff --git a/packages/slots/test/__snapshots__/examples.browser.test.tsx.snap b/packages/slots/test/__snapshots__/examples.browser.test.tsx.snap new file mode 100644 index 000000000..474866f3b --- /dev/null +++ b/packages/slots/test/__snapshots__/examples.browser.test.tsx.snap @@ -0,0 +1,21 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`@bento/slots examples > Button > should render a button 1`] = `""`; + +exports[`@bento/slots examples > Memo > should render a memoized component 1`] = `"
"`; + +exports[`@bento/slots examples > Merged > should render a component with the correct merged slots 1`] = `"

Description: Merged summaryBetter errorExpect class "merged-class", id "merged" and title "better-title".

"`; + +exports[`@bento/slots examples > MergedFunction > should render all enhancement levels in correct order 1`] = `"
First Enhancement
Second Enhancement
Third Enhancement: merged-fn
"`; + +exports[`@bento/slots examples > Namespace > should allow children to inherit root-level slots 1`] = `"
Content
"`; + +exports[`@bento/slots examples > Namespace > should render a component with correct replacements 1`] = `""`; + +exports[`@bento/slots examples > Namespace > should render a component with correct slot names 1`] = `""`; + +exports[`@bento/slots examples > Namespace > should render a component with slots 1`] = `""`; + +exports[`@bento/slots examples > SlotFunction > should render a slot function override 1`] = `"
"`; + +exports[`@bento/slots examples > SlotProps > should render a component with slot props, creating a red button 1`] = `"
"`; diff --git a/packages/slots/test/examples.browser.test.tsx b/packages/slots/test/examples.browser.test.tsx index 066684bf4..924910826 100644 --- a/packages/slots/test/examples.browser.test.tsx +++ b/packages/slots/test/examples.browser.test.tsx @@ -12,7 +12,7 @@ import { NamespaceWithReplacements, NamespaceRootLevelInheritance } from '../examples/namespace.tsx'; -import { describe, it } from 'vitest'; +import { describe, it, expect } from 'vitest'; import assume from 'assume'; import React from 'react'; @@ -20,16 +20,13 @@ describe('@bento/slots examples', function bento() { describe('Button', function button() { it('should render a button', function test() { const { container } = render(); - const result = container.innerHTML; - - assume(result).equals(''); + expect(container.innerHTML).toMatchSnapshot(); }); }); describe('Memo', function memo() { it('should render a memoized component', async function test() { const screen = render(); - const result = screen.container.innerHTML; const logger = console.log; let logs: any[] = []; @@ -37,9 +34,7 @@ describe('@bento/slots examples', function bento() { logs = args; }; - assume(result).equals( - '
' - ); + expect(screen.container.innerHTML).toMatchSnapshot(); await screen.getByRole('button', { name: 'Click me' }).click(); console.log = logger; @@ -53,18 +48,13 @@ describe('@bento/slots examples', function bento() { describe('SlotFunction', function slotfn() { it('should render a slot function override', function slotted() { const { container } = render(); - const result = container.innerHTML; - - assume(result).equals( - '
' - ); + expect(container.innerHTML).toMatchSnapshot(); }); }); describe('SlotProps', function slotprops() { it('should render a component with slot props, creating a red button', async function props() { const screen = render(); - const result = screen.container.innerHTML; const logger = console.log; let logs: any[] = []; @@ -72,9 +62,7 @@ describe('@bento/slots examples', function bento() { logs = args; }; - assume(result).equals( - '
' - ); + expect(screen.container.innerHTML).toMatchSnapshot(); await screen.getByRole('button', { name: 'Click me' }).click(); console.log = logger; @@ -87,70 +75,36 @@ describe('@bento/slots examples', function bento() { describe('Merged', function merged() { it('should render a component with the correct merged slots', function test() { const { container } = render(); - const result = container.innerHTML; - - assume(result).equals( - [ - '

', - ' Description: ', - ' ', - ' Merged summary', - ' Better error', - ' Expect class "merged-class", id "merged" and title "better-title".', - '

' - ] - .join('\n') - .replace(/^\s+/gm, '') - .replace(/\n/g, '') - ); + expect(container.innerHTML).toMatchSnapshot(); }); }); describe('MergedFunction', function mergedFunction() { it('should render all enhancement levels in correct order', function allLevels() { const { container } = render(); - const result = container.innerHTML; - - assume(result).equals( - '
' + - '
First Enhancement
' + - '
Second Enhancement
' + - '
Third Enhancement: merged-fn
' + - '
' - ); + expect(container.innerHTML).toMatchSnapshot(); }); }); describe('Namespace', function namespace() { it('should render a component with slots', function test() { const { container } = render(); - const result = container.innerHTML; - - assume(result).equals('' + ''); + expect(container.innerHTML).toMatchSnapshot(); }); it('should render a component with correct slot names', function test() { const { container } = render(); - const result = container.innerHTML; - - assume(result).equals('' + ''); + expect(container.innerHTML).toMatchSnapshot(); }); it('should render a component with correct replacements', function test() { const { container } = render(); - const result = container.innerHTML; - - assume(result).equals('' + ''); + expect(container.innerHTML).toMatchSnapshot(); }); it('should allow children to inherit root-level slots', function test() { const { container } = render(); - const result = container.innerHTML; - - console.log('bbbb', result); - - assume(result).equals('
' + 'Content' + '
'); + expect(container.innerHTML).toMatchSnapshot(); }); }); diff --git a/packages/slots/test/override.node.test.ts b/packages/slots/test/override.node.test.ts index b634017ad..23cddd493 100644 --- a/packages/slots/test/override.node.test.ts +++ b/packages/slots/test/override.node.test.ts @@ -36,37 +36,38 @@ describe('@bento/slots override', function bento() { describe('default behavior (no locked Environment)', function defaultBehavior() { it('does not introduce data-override by default', function noOverride() { const html = createComponent('noOverride', { id: 'example', role: 'presentation' }); - assume(html).contains(''); + // data-slot="test" is added for slotted components (from main) + assume(html).contains(''); assume(html).does.not.contain('data-override'); }); it('does not add data-override when style is present', function style() { const html = createComponent('style', { style: { color: 'red' } }); - assume(html).contains('
'); + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); it('does not introduce data-override for CSS variables', function cssVariables() { const html = createComponent('cssVariables', { style: { '--color': 'red' } }); - assume(html).contains('
'); + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); it('does not add data-override when CSS variables and style are present', function cssVariablesAndStyle() { const html = createComponent('cssVariablesAndStyle', { style: { '--color': 'red', color: 'blue' } }); - assume(html).contains('
'); + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); it('does not introduce data-override when className is present', function className() { const html = createComponent('className', { className: 'example' }); - assume(html).contains('
'); + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); it('does not introduce data-override when existing data-override is present', function existing() { const html = createComponent('existing', { style: { color: 'red' }, 'data-override': 'boink' }); - assume(html).contains('
'); + assume(html).contains('
'); }); it('does not add data-override for multiple style triggers', function multiple() { @@ -75,7 +76,7 @@ describe('@bento/slots override', function bento() { className: 'example' }); - assume(html).contains('
'); + assume(html).contains('
'); assume(html).does.not.contain('data-override="'); }); @@ -88,7 +89,7 @@ describe('@bento/slots override', function bento() { } ); - assume(html).contains('
'); + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); @@ -107,14 +108,14 @@ describe('@bento/slots override', function bento() { } ); - assume(html).contains('
'); + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); }); describe('locked Environment behavior', function lockedBehavior() { - it('introduces data-override when style is present in locked environment', function styleWithLock() { - // First need to set up a scenario where slot is from earlier generation + it('introduces data-override when style is present via slot in locked environment', function styleWithLock() { + // Style must be passed via slots (not direct props) to trigger override detection const InnerComponent = withSlots('StyleLockInner', function Component(args: any) { const { props } = useProps(args); return React.createElement('div', props); @@ -127,12 +128,17 @@ describe('@bento/slots override', function bento() { }); }); - const html = renderToString(React.createElement(LockedComponent, { style: { color: 'red' } })); + // Pass style via slots prop to trigger slot-based override detection + const html = renderToString( + React.createElement(LockedComponent, { slots: { test: { style: { color: 'red' } } } }) + ); - assume(html).contains('data-override="style"'); + assume(html).contains('data-override'); + assume(html).contains('style'); + assume(html).contains('data-slot="test"'); }); - it('introduces data-override when className is present in locked environment', function classNameWithLock() { + it('introduces data-override when className is present via slot in locked environment', function classNameWithLock() { const InnerComponent = withSlots('ClassLockInner', function Component(args: any) { const { props } = useProps(args); return React.createElement('div', props); @@ -145,9 +151,12 @@ describe('@bento/slots override', function bento() { }); }); - const html = renderToString(React.createElement(LockedComponent, { className: 'example' })); + // Pass className via slots prop to trigger slot-based override detection + const html = renderToString(React.createElement(LockedComponent, { slots: { test: { className: 'example' } } })); - assume(html).contains('data-override="className"'); + assume(html).contains('data-override'); + assume(html).contains('className'); + assume(html).contains('data-slot="test"'); }); it('does not introduce data-override for CSS variables in locked environment', function cssVariablesLocked() { @@ -163,9 +172,13 @@ describe('@bento/slots override', function bento() { }); }); - const html = renderToString(React.createElement(LockedComponent, { style: { '--color': 'red' } })); + const html = renderToString( + React.createElement(LockedComponent, { slots: { test: { style: { '--color': 'red' } } } }) + ); - assume(html).contains('
'); + // CSS-variable-only style should NOT trigger data-override + assume(html).contains('style="--color:red"'); + assume(html).contains('data-slot="test"'); assume(html).does.not.contain('data-override'); }); @@ -184,12 +197,17 @@ describe('@bento/slots override', function bento() { const html = renderToString( React.createElement(LockedComponent, { - style: { color: 'red' }, - className: 'example' + slots: { + test: { + style: { color: 'red' }, + className: 'example' + } + } }) ); - assume(html).contains('data-override="className style"'); + assume(html).contains('data-override="className style slot"'); + assume(html).contains('data-slot="test"'); }); }); @@ -264,21 +282,31 @@ describe('@bento/slots override', function bento() { }); describe('lock-based override detection', function lockBasedOverrides() { - it('flags className when passed to component inside locked environment', function lockedSameGen() { + it('flags className when passed via slots to component inside locked environment', function lockedSameGen() { const TestComponent = withSlots('LockSameGen', function Component(args: any) { const { props } = useProps(args); return React.createElement('div', props); }); - const html = renderToString( - React.createElement(Environment, { + // Wrap in a design system component pattern: slots are passed from outside the lock + const DesignSystemComponent = withSlots('LockSameGenDesign', function Component(props: any) { + return React.createElement(Environment, { lock: true, - children: React.createElement(TestComponent, { slot: 'test', className: 'internal' }) + children: React.createElement(TestComponent, { ...props, slot: 'test' }) + }); + }); + + // Pass className via slots from the "consumer" (before lock) + const html = renderToString( + React.createElement(DesignSystemComponent, { + slots: { test: { className: 'internal' } } }) ); - // SHOULD have data-override since className is passed from outside the lock - assume(html).contains('
'); + // SHOULD have data-override since className is passed via slot from outside the lock + assume(html).contains('data-override'); + assume(html).contains('className'); + assume(html).contains('class="internal"'); }); it('flags className when environment is locked and slot is from earlier generation', function lockedEarlierGen() { @@ -297,16 +325,17 @@ describe('@bento/slots override', function bento() { const html = renderToString( React.createElement(Environment, { children: React.createElement(LockedDesignComponent, { - className: 'consumer', slots: { - test: {} + test: { className: 'consumer' } } }) }) ); // SHOULD have data-override since it's from earlier generation (consumer modification) - assume(html).contains('
'); + assume(html).contains('data-override="className slot"'); + assume(html).contains('class="consumer"'); + assume(html).contains('data-slot="test"'); }); it('flags slot modifications from earlier generation even without className or style', function slotOnly() { @@ -332,7 +361,7 @@ describe('@bento/slots override', function bento() { }) ); - assume(html).contains('
Hello
'); + assume(html).contains('
Hello
'); }); it('does not flag slots when not locked', function notLocked() { @@ -348,16 +377,17 @@ describe('@bento/slots override', function bento() { const html = renderToString( React.createElement(Environment, { children: React.createElement(Component, { - style: { color: 'red' }, slots: { - test: { className: 'test' } + test: { className: 'test', style: { color: 'red' } } } }) }) ); // Without lock, no data-override should be added - assume(html).contains('
'); + assume(html).contains('class="test"'); + assume(html).contains('style="color:red"'); + assume(html).contains('data-slot="test"'); assume(html).does.not.contain('data-override'); }); @@ -377,15 +407,16 @@ describe('@bento/slots override', function bento() { const html = renderToString( React.createElement(Environment, { children: React.createElement(LockedDesignComponent, { - style: { color: 'blue' }, slots: { - test: {} + test: { style: { color: 'blue' } } } }) }) ); - assume(html).contains('
'); + assume(html).contains('data-override="style slot"'); + assume(html).contains('style="color:blue"'); + assume(html).contains('data-slot="test"'); }); }); }); diff --git a/packages/slots/test/replace.node.test.ts b/packages/slots/test/replace.node.test.ts index dbe1b357f..a466869c6 100644 --- a/packages/slots/test/replace.node.test.ts +++ b/packages/slots/test/replace.node.test.ts @@ -81,7 +81,8 @@ describe('@bento/slots replace', function bento() { false ); - assume(html).contains('
'); + // data-slot="test" is added for slotted components (from main) + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); @@ -98,7 +99,7 @@ describe('@bento/slots replace', function bento() { true ); - assume(html).contains('
'); + assume(html).contains('
'); }); }); @@ -118,7 +119,8 @@ describe('@bento/slots replace', function bento() { false ); - assume(html).contains('
'); + // data-slot="test" is added for slotted components (from main) + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); @@ -138,7 +140,9 @@ describe('@bento/slots replace', function bento() { ); // Context and className should both be flagged - assume(html).contains('
'); + assume(html).contains( + '
' + ); }); }); @@ -156,7 +160,8 @@ describe('@bento/slots replace', function bento() { false ); - assume(html).contains('
'); + // data-slot="test" is added for slotted components (from main) + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); @@ -173,7 +178,7 @@ describe('@bento/slots replace', function bento() { true ); - assume(html).contains('
'); + assume(html).contains('
'); }); }); @@ -188,7 +193,8 @@ describe('@bento/slots replace', function bento() { false ); - assume(html).contains('
'); + // data-slot="test" is added for slotted components (from main) + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); @@ -202,7 +208,8 @@ describe('@bento/slots replace', function bento() { true ); - assume(html).contains('
'); + // data-slot="test" is added for slotted components (from main) + assume(html).contains('
'); assume(html).does.not.contain('data-override'); }); }); diff --git a/packages/slots/test/slots.node.test.ts b/packages/slots/test/slots.node.test.ts index e4fe8e9f1..075fa39c2 100644 --- a/packages/slots/test/slots.node.test.ts +++ b/packages/slots/test/slots.node.test.ts @@ -110,7 +110,8 @@ describe('@bento/slots', function bento() { assume(nested).contains('Hello World'); assume(nested).does.not.contain('Click Me'); - assume(nested).contains(`

No more button, only text

`); + // data-slot is added for slotted components (from main) + assume(nested).contains(`

No more button, only text

`); }); it('flags component overrides with context when locked', function overrideLocked() { @@ -134,7 +135,8 @@ describe('@bento/slots', function bento() { assume(nested).contains('Hello World'); assume(nested).does.not.contain('Click Me'); - assume(nested).contains(`

No more button, only text

`); + // data-slot is added for slotted components (from main) + assume(nested).contains(`

No more button, only text

`); }); }); @@ -168,8 +170,12 @@ describe('@bento/slots', function bento() { }) ); - // Verify slot is applied (with data-override since lockGeneration > 0) - assume(html).contains('data-override="slot className"'); + // Verify slot is applied. Since the slots are passed INSIDE the locked environment + // (internal composition), they should NOT trigger data-override. + // data-slot is added for slotted components (from main) + assume(html).does.not.contain('data-override'); + assume(html).contains('data-slot="child"'); + assume(html).contains('class="tracked"'); assume(html).contains('child'); }); @@ -191,7 +197,8 @@ describe('@bento/slots', function bento() { }) ); - assume(html).contains(''); + // data-slot is added for slotted components (from main) + assume(html).contains(''); assume(html).does.not.contain('data-override'); }); diff --git a/packages/slots/test/slots.node.test.tsx b/packages/slots/test/slots.node.test.tsx index 9a30fa96e..c2d6236b0 100644 --- a/packages/slots/test/slots.node.test.tsx +++ b/packages/slots/test/slots.node.test.tsx @@ -94,6 +94,9 @@ describe('@bento/slots', function bento() { let id; const value = defaults(); + // Set locked: true to trigger data-override behavior + value.env.locked = true; + value.env.lockGeneration = 1; value.env.components = { SlotsButton: function Button(props) { assume(props['data-override']).equals('context'); @@ -112,6 +115,7 @@ describe('@bento/slots', function bento() { assume(nested).contains('Hello World'); assume(nested).does.not.contain('Click Me'); + // data-slot is added for slotted components (from main) assume(nested).contains(`

No more button, only text

`); }); }); diff --git a/packages/use-props/test/__snapshots__/examples.browser.test.tsx.snap b/packages/use-props/test/__snapshots__/examples.browser.test.tsx.snap new file mode 100644 index 000000000..4fa7aeb33 --- /dev/null +++ b/packages/use-props/test/__snapshots__/examples.browser.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`@bento/use-props examples > Apply Button > should handle additional props correctly 1`] = `""`; + +exports[`@bento/use-props examples > Apply Button > should render a button with applied className 1`] = `""`; + +exports[`@bento/use-props examples > Apply and Omit Button > should handle props correctly 1`] = `""`; + +exports[`@bento/use-props examples > Apply and Omit Button > should render a button with applied className and omitted props 1`] = `""`; + +exports[`@bento/use-props examples > Basic Button > can be rendered as an anchor with className 1`] = `"Click this link"`; + +exports[`@bento/use-props examples > Basic Button > should render a button without a className 1`] = `""`; + +exports[`@bento/use-props examples > Memo > should render a memoized button 1`] = `"This button has a different class Name"`; + +exports[`@bento/use-props examples > Nested > renders the changes from a slot assignment 1`] = `"
Click Me
"`; diff --git a/packages/use-props/test/examples.browser.test.tsx b/packages/use-props/test/examples.browser.test.tsx index 937c7f61b..2bfaddb21 100644 --- a/packages/use-props/test/examples.browser.test.tsx +++ b/packages/use-props/test/examples.browser.test.tsx @@ -4,19 +4,14 @@ import { Button as BasicButton } from '../examples/button.tsx'; import { Nested } from '../examples/nested.tsx'; import { Memo } from '../examples/memo.tsx'; import { render } from 'vitest-browser-react'; -import { describe, it } from 'vitest'; -import assume from 'assume'; +import { describe, it, expect } from 'vitest'; import React from 'react'; describe('@bento/use-props examples', function bento() { describe('Basic Button', function button() { it('should render a button without a className', function test() { const { container } = render(Click this button); - const result = container.innerHTML; - - assume(result).includes('button'); - assume(result).includes('Click this button'); - assume(result).does.not.includes('class="xyz-hashed-class"'); + expect(container.innerHTML).toMatchSnapshot(); }); it('can be rendered as an anchor with className', function test() { @@ -25,25 +20,14 @@ describe('@bento/use-props examples', function bento() { Click this link ); - const result = container.innerHTML; - - assume(result).does.not.includes('button'); - assume(result).startsWith(''); - assume(result).includes('href="foo.bar"'); - assume(result).includes('Click this link'); - assume(result).includes('class="xyz-hashed-class"'); + expect(container.innerHTML).toMatchSnapshot(); }); }); describe('Apply Button', function applyButton() { it('should render a button with applied className', function test() { const { container } = render(Click this button); - const result = container.innerHTML; - - assume(result).includes('button'); - assume(result).includes('Click this button'); - assume(result).includes('class="xyz-hashed-class"'); + expect(container.innerHTML).toMatchSnapshot(); }); it('should handle additional props correctly', function test() { @@ -52,23 +36,14 @@ describe('@bento/use-props examples', function bento() { Click this button ); - const result = container.innerHTML; - - assume(result).includes('button'); - assume(result).includes('data-testid="test"'); - assume(result).includes('disabled'); - assume(result).includes('class="xyz-hashed-class"'); + expect(container.innerHTML).toMatchSnapshot(); }); }); describe('Apply and Omit Button', function applyOmitButton() { it('should render a button with applied className and omitted props', function test() { const { container } = render(Click this button); - const result = container.innerHTML; - - assume(result).includes('button'); - assume(result).includes('Click this button'); - assume(result).includes('class="xyz-hashed-class"'); + expect(container.innerHTML).toMatchSnapshot(); }); it('should handle props correctly', function test() { @@ -77,24 +52,14 @@ describe('@bento/use-props examples', function bento() { Click this link ); - const result = container.innerHTML; - - assume(result).includes('button'); - assume(result).includes('data-testid="test"'); - assume(result).includes('href="test.com"'); - assume(result).includes('class="xyz-hashed-class"'); + expect(container.innerHTML).toMatchSnapshot(); }); }); describe('Memo', function memo() { it('should render a memoized button', function test() { const { container } = render(); - const result = container.innerHTML; - - assume(result).includes('href="https://example.com" target="_blank"'); - assume(result).includes('This button has a different class Name'); - assume(result).includes('class="xyz-hashed-class my-className my-class"'); - assume(result).includes('data-override="className"'); + expect(container.innerHTML).toMatchSnapshot(); }); }); @@ -114,11 +79,7 @@ describe('@bento/use-props examples', function bento() { }} /> ); - - const result = container.innerHTML; - assume(result).includes('Click Me'); - assume(result).includes('href="https://example.com"'); - assume(result).includes('class="xyz-hashed-class button"'); + expect(container.innerHTML).toMatchSnapshot(); }); }); }); diff --git a/packages/use-props/test/use-props.node.test.tsx b/packages/use-props/test/use-props.node.test.tsx index 00026d1fe..7b6ca7b7e 100644 --- a/packages/use-props/test/use-props.node.test.tsx +++ b/packages/use-props/test/use-props.node.test.tsx @@ -136,7 +136,7 @@ describe('@bento/use-props', function bento() { * @property {function} apply - The apply function returned by useProps. * @private */ - function createComponent(name: string, props = {}, slots = {}) { + function createComponent(name: string, props = {}, slots = {}, locked = false) { let result: any; const TestReturn = withSlots(`BentoRenderProps-${name}`, function Component(args) { @@ -150,6 +150,14 @@ describe('@bento/use-props', function bento() { context.slots.namespace = []; context.slots.override = false; + // Set up locked environment if requested + if (locked) { + context.env.locked = true; + context.env.lockGeneration = 1; + // Mark the slot as from an earlier generation (before lock) + context.slots.slotGenerations = { test: 0 }; + } + return { html: renderToString( React.createElement( @@ -298,7 +306,8 @@ describe('@bento/use-props', function bento() { }, { id: 'modified' - } + }, + true // locked environment to trigger data-override ); const result = apply(); @@ -340,7 +349,8 @@ describe('@bento/use-props', function bento() { }, { id: 'modified' - } + }, + true // locked environment to trigger data-override ); const result = apply({ id: 'hello-there' }); From 5fd7f7502caca27bf5a630dadcd34af31ad46f1c Mon Sep 17 00:00:00 2001 From: 3rd-Eden Date: Fri, 19 Dec 2025 01:17:12 +0100 Subject: [PATCH 16/19] docs: clean up --- docs/pdrs/composition-guidelines.mdx | 590 ---------- docs/pdrs/old-overlay.mdx | 1050 ----------------- docs/pdrs/overlay-claude.mdx | 1571 -------------------------- docs/pdrs/overlay-composer-1.mdx | 798 ------------- docs/pdrs/overlay-composer-2.mdx | 1401 ----------------------- docs/pdrs/overlay-composer-3.mdx | 851 -------------- docs/pdrs/overlay-gpt.mdx | 539 --------- docs/pdrs/use-stack.mdx | 329 ------ 8 files changed, 7129 deletions(-) delete mode 100644 docs/pdrs/composition-guidelines.mdx delete mode 100644 docs/pdrs/old-overlay.mdx delete mode 100644 docs/pdrs/overlay-claude.mdx delete mode 100644 docs/pdrs/overlay-composer-1.mdx delete mode 100644 docs/pdrs/overlay-composer-2.mdx delete mode 100644 docs/pdrs/overlay-composer-3.mdx delete mode 100644 docs/pdrs/overlay-gpt.mdx delete mode 100644 docs/pdrs/use-stack.mdx diff --git a/docs/pdrs/composition-guidelines.mdx b/docs/pdrs/composition-guidelines.mdx deleted file mode 100644 index df356f8fe..000000000 --- a/docs/pdrs/composition-guidelines.mdx +++ /dev/null @@ -1,590 +0,0 @@ -# Composition Guidelines PDR - -## Purpose - -Establishes guidelines for component composition using Bento primitives and slots. Defines how to build composite components that introduce logic while remaining composable and customizable. - -## Core Principle - -**Pass state through slots.** Composite components distribute state and handlers via the slot system, keeping state flow explicit and customizable. - -## Guidelines - -### 1. State Distribution - -**Always pass state/handlers via slots:** - -```tsx -import { useSelectState } from '@react-stately/select'; -import { useSelect } from '@react-aria/select'; -import { useRef } from 'react'; -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; - -export const Select = withSlots('BentoSelect', function Select(props) { - const state = useSelectState(props); - const triggerRef = useRef(null); - const { triggerProps, menuProps } = useSelect(props, state, triggerRef); - - // useProps is required for slots to work - const { props: processedProps } = useProps(props, { - isInvalid: state.isInvalid, - isOpen: state.isOpen, - selectedKeys: state.selectedKeys - }); - - // Data attributes expose state/props for debugging/styling - const dataAttributes = useDataAttributes({ - invalid: state.isInvalid, - open: state.isOpen - }); - - // Pass state handlers via slots - withSlots merges automatically - return ( - - ); -}); -``` - -**For state needed deep in tree (not direct slot children), extend Box context `env`:** - -```tsx -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; -import { Box } from '@bento/box'; -import { useContext } from 'react'; - -const { props: processedProps } = useProps(props, { - isInvalid: state.isInvalid, - isOpen: state.isOpen, - selectedKeys: state.selectedKeys -}); - -const dataAttributes = useDataAttributes({ - invalid: state.isInvalid, - open: state.isOpen -}); - -const boxContext = useContext(Box); -const enhancedContext = { - ...boxContext, - env: { ...boxContext.env, selectState: state } -}; -return ( - - ... - -); -``` - -**Do not create separate component contexts** - they hide state flow and break the slot-based model. - -### 2. Choosing Primitives - -**Use Container when:** -- Component needs polymorphic rendering (`as` prop) -- Component exposes internal structure via slots -- Component accepts arbitrary HTML attributes - -**Use raw HTML when:** -- No internal structure to customize -- Always renders same element -- Simple leaf component - -**Use Box when:** -- Managing environment/component replacement -- Providing root-level infrastructure - -Most composition should use Container. - -### 3. Slot Naming - -**Name by semantic purpose, not implementation:** - -✅ Good: `trigger`, `content`, `label`, `description`, `errorMessage` -❌ Avoid: `button`, `div`, `wrapper` - -**For nested slots, use dot-separated namespaces:** -- `trigger` - Top-level trigger -- `trigger.label` - Text inside trigger -- `trigger.icon` - Icon inside trigger -- `popover.list` - ListBox inside popover - -**Common slot names:** -- `root` - Top-level container -- `trigger` - Activator element -- `content` - Main content area -- `label` - Accessible label -- `description` - Descriptive text -- `errorMessage` - Error feedback -- `icon` - Visual icon -- `header` / `footer` - Section containers - -### 4. Component Structure - -**Pattern 1: Logic-only (no wrapper element)** - -```tsx -import { useSelectState } from '@react-stately/select'; -import { useSelect } from '@react-aria/select'; -import { useRef } from 'react'; -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; - -export const Select = withSlots('BentoSelect', function Select(props) { - const state = useSelectState(props); - const triggerRef = useRef(null); - const { triggerProps, menuProps } = useSelect(props, state, triggerRef); - - const { props: processedProps } = useProps(props, { - isInvalid: state.isInvalid, - isOpen: state.isOpen, - selectedKeys: state.selectedKeys - }); - - const dataAttributes = useDataAttributes({ - invalid: state.isInvalid, - open: state.isOpen - }); - - // Pass state handlers via slots - withSlots merges with consumer slots automatically - return ( - - ); -}); -``` - -**Pattern 2: With wrapper element** - -```tsx -import { useSelectState } from '@react-stately/select'; -import { useSelect } from '@react-aria/select'; -import { useRef } from 'react'; -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; - -export const Select = withSlots('BentoSelect', function Select(props) { - const state = useSelectState(props); - const triggerRef = useRef(null); - const { triggerProps, menuProps } = useSelect(props, state, triggerRef); - - const { props: processedProps } = useProps(props, { - isInvalid: state.isInvalid, - isOpen: state.isOpen, - selectedKeys: state.selectedKeys - }); - - const dataAttributes = useDataAttributes({ - invalid: state.isInvalid, - open: state.isOpen - }); - - // Pass state handlers via slots - withSlots merges with consumer slots automatically - return ( - - ); -}); -``` - -### 5. Logic Distribution - -**Parent component (composite):** -- Creates state -- Passes handlers via slots -- Manages ARIA relationships - -**Child primitives:** -- Handle their own behavior (press, selection, keyboard nav) -- Receive state/handlers via slots -- Manage their own accessibility attributes - -### 6. Conditional Rendering - -**Multiple patterns for conditional rendering:** - -#### Pattern 1: Slots set to null - -Slots can be conditionally set to `null` to prevent rendering: - -```tsx -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; - -export const Select = withSlots('BentoSelect', function Select(props) { - const state = useSelectState(props); - const triggerRef = useRef(null); - const { triggerProps, menuProps, descriptionProps, errorMessageProps } = useSelect(props, state, triggerRef); - - const { props: processedProps } = useProps(props, { - isInvalid: state.isInvalid, - isOpen: state.isOpen, - selectedKeys: state.selectedKeys - }); - - const dataAttributes = useDataAttributes({ - invalid: state.isInvalid, - open: state.isOpen - }); - - return ( - - ); -}); - -// Usage - consumers provide children, component controls via slots - -``` - -#### Pattern 2: Function slots with conditional logic - -Function slots can conditionally render based on state: - -```tsx -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; - -export const Select = withSlots('BentoSelect', function Select(props) { - const state = useSelectState(props); - const triggerRef = useRef(null); - const { triggerProps, menuProps, errorMessageProps } = useSelect(props, state, triggerRef); - - const { props: processedProps } = useProps(props, { - isInvalid: state.isInvalid, - isOpen: state.isOpen, - selectedKeys: state.selectedKeys - }); - - const dataAttributes = useDataAttributes({ - invalid: state.isInvalid, - open: state.isOpen - }); - - return ( - - state.isInvalid ? {original} : null - }} - /> - ); -}); -``` - -#### Pattern 3: Render props via children - -Children can be a function that receives state/props. `useProps` automatically handles render props: - -```tsx -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; - -export const Select = withSlots('BentoSelect', function Select(props) { - const state = useSelectState(props); - const triggerRef = useRef(null); - const { triggerProps, menuProps } = useSelect(props, state, triggerRef); - - // Pass state to useProps - it automatically processes render props (including children) - const { props: processedProps } = useProps(props, { - isInvalid: state.isInvalid, - isOpen: state.isOpen, - selectedKeys: state.selectedKeys - }); - - const dataAttributes = useDataAttributes({ - invalid: state.isInvalid, - open: state.isOpen - }); - - return ( - - ); -}); - -// Usage - children function automatically receives state via useProps - -``` - -#### Pattern 4: Render props via props - -Any prop can be a render function that receives state. `useProps` automatically processes render props: - -```tsx -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; - -export const Select = withSlots('BentoSelect', function Select(props) { - const state = useSelectState(props); - const triggerRef = useRef(null); - const { triggerProps, menuProps, descriptionProps, errorMessageProps } = useSelect(props, state, triggerRef); - - // useProps automatically processes render props (including description, errorMessage, etc.) - const { props: processedProps } = useProps(props, { - isInvalid: state.isInvalid, - isOpen: state.isOpen, - selectedKeys: state.selectedKeys - }); - - const dataAttributes = useDataAttributes({ - invalid: state.isInvalid, - open: state.isOpen - }); - - return ( - - {processedProps.description && ( - - {processedProps.description} - - )} - {processedProps.errorMessage && ( - - {processedProps.errorMessage} - - )} - - ); -}); - -// Usage - any prop can be a render function, automatically processed by useProps - -``` - -**Key principle:** Use `null` in slots for simple conditional rendering. Use function slots for complex conditional logic. Use render props (children or prop functions) when consumers need access to state. - -### 7. Slot Customization - -**Object slots** - merge props: -```tsx - ( - {original} - ) -}} /> -``` - -## Example: Select Component - -```tsx -import { Container } from '@bento/container'; -import { Button } from '@bento/button'; -import { Popover } from '@bento/popover'; -import { ListBox, ListBoxItem } from '@bento/listbox'; -import { Text } from '@bento/text'; -import { withSlots, Slots } from '@bento/slots'; -import { useSelectState } from '@react-stately/select'; -import { useSelect } from '@react-aria/select'; -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; -import React, { useRef } from 'react'; - -export interface SelectProps extends Slots { - children?: React.ReactNode; -} - -export const Select = withSlots('BentoSelect', function Select(props: SelectProps) { - const state = useSelectState(props); - const triggerRef = useRef(null); - const { triggerProps, menuProps, descriptionProps, errorMessageProps } = useSelect(props, state, triggerRef); - - // useProps is required for slots to work - const { props: processedProps } = useProps(props, { - isInvalid: state.isInvalid, - isOpen: state.isOpen, - selectedKeys: state.selectedKeys - }); - - // Data attributes expose state for debugging/styling - const dataAttributes = useDataAttributes({ - invalid: state.isInvalid, - open: state.isOpen - }); - - // Filter children by slot for conditional rendering - const childArray = React.Children.toArray(processedProps.children); - const requiredChildren = childArray.filter((child) => { - if (!React.isValidElement(child)) return true; - const slot = child.props.slot; - return slot !== 'description' && slot !== 'errorMessage'; - }); - - const descriptionChild = childArray.find((child) => - React.isValidElement(child) && child.props.slot === 'description' - ); - - const errorChild = childArray.find((child) => - React.isValidElement(child) && child.props.slot === 'errorMessage' - ); - - return ( - - {requiredChildren} - - {/* Component controls IF these render based on state */} - {descriptionChild && ( - - {descriptionChild} - - )} - - {state.isInvalid && errorChild && ( - - {errorChild} - - )} - - ); -}); - -// Usage - consumers provide everything as children, component controls visibility - - -// Component shows errorMessage only when state.isInvalid is true -// Consumers customize via slots - -``` - -## Decision Checklist - -When building a composite component: - -- [ ] What state/handlers need to be passed to children? -- [ ] Are they being passed via slots? (they should be) -- [ ] Does component need a wrapper element? -- [ ] Which slots are required vs optional? -- [ ] What are the semantic slot names? -- [ ] What ARIA relationships need to be established? -- [ ] How should focus be managed? diff --git a/docs/pdrs/old-overlay.mdx b/docs/pdrs/old-overlay.mdx deleted file mode 100644 index 8aee51b78..000000000 --- a/docs/pdrs/old-overlay.mdx +++ /dev/null @@ -1,1050 +0,0 @@ -# Overlay - -## Purpose - -The Overlay primitive serves as a foundational building block for creating layered UI experiences where content appears above the main application view. It provides the core backdrop functionality that supports various overlay-based patterns such as modals, dialogs, drawers, and loading indicators. - -The Overlay primitive addresses several key scenarios: - -- Displaying content that requires immediate user attention or action -- Creating focused interaction spaces that temporarily obscure the main content -- Supporting mobile-first patterns like drawers, sheets, and action menus -- Enabling rich compositional patterns through layering of UI elements - -This primitive will typically be consumed by: -- Application developers building modal interfaces or drawers -- Component authors creating specialized dialog components -- Teams building loading indicators or notification systems -- Any UI pattern requiring a visual layer between content and application - -### Unique Attributes - -The Overlay primitive differs from similar components in these key ways: - -- **Pure foundation**: Unlike most dialog components that combine multiple concerns, the Overlay provides only the backdrop and layering functionality, allowing maximum composition flexibility -- **Layering management**: Built-in support for handling multiple overlays with proper stacking context and accessibility -- **Focus management**: Integrated focus trapping capabilities that follow WAI-ARIA best practices -- **Scroll locking**: Cross-browser scroll management that prevents background content scrolling -- **Animation support**: First-class support for entrance/exit animations and transitions - -## Primitive Composition - -The Overlay primitive composes several standalone reusable primitives, maximizing code sharing across the Bento ecosystem: - -### Core Primitive Packages (New) -Each primitive will be its own package following Bento's architecture: - -- **@bento/layer-stack**: Manages stacking order for all layered UI elements -- **@bento/portal**: Handles DOM rendering placement -- **@bento/focus-scope**: Contains keyboard focus within a boundary -- **@bento/scroll-lock**: Prevents background scrolling -- **@bento/visually-hidden**: Provides screen reader only content -- **@bento/dismiss-button**: Accessible dismiss functionality - -### Overlay Components -- **OverlayRoot**: State container that manages overlay visibility -- **OverlayBackdrop**: Visual backdrop that sits behind content -- **OverlayContent**: Container for overlay content with proper layering and focus handling -- **OverlayTrigger**: Element that triggers the overlay -- **OverlayClose**: Button for dismissing the overlay - -### React Aria Integration -All primitives build directly upon React Aria's accessibility-focused hooks: -- **useModalOverlay**: For backdrop and modal behavior -- **useFocusScope**: For focus containment -- **usePreventScroll**: For scroll locking -- **useOverlayTrigger**: For trigger elements -- **useOverlay**: For core overlay behavior - -## Implementation Patterns - -| Concept | Implementation | -|---------------------|--------------------------------------------------------------| -| React Aria | useModalOverlay, useFocusScope, useOverlayTrigger, useOverlay | -| Slots | withSlots for component customization | -| Render Props | Support for className and style render props with meta object | -| data-* Attributes | data-state, data-open, data-animated, data-placement | -| data-override | Used when user customizes backdrop or content behavior | -| data-version | Dev-only attribute for version tracking | -| Composability | Support for nested content, multiple overlays, context providers | - -## Internal Structure & Reuse Potential - -Each primitive is designed for maximum reusability across the Bento ecosystem: - -### @bento/layer-stack - -The LayerStack primitive provides a generalized system for managing any kind of layered UI element, not just overlays. This enables consistent stacking behavior, focus management, and accessibility across different UI patterns. - -```jsx -import { createContext, useContext, useState, useCallback, useMemo } from 'react'; -import { withSlots } from '@bento/slots'; -import { useRenderProps } from '@bento/use-render-props'; - -const LayerContext = createContext(null); - -export const useLayerStack = () => { - const context = useContext(LayerContext); - if (context === null) { - throw new Error('useLayerStack must be used within a LayerProvider'); - } - return context; -}; - -export const Provider = withSlots('BentoLayerProvider', function LayerProvider(args) { - const [props, apply] = useRenderProps(args); - const { children } = props; - const [layers, setLayers] = useState([]); - - // Register a new layer with optional type and configuration - const register = useCallback((id, options = {}) => { - const type = options.type || 'generic'; - const newLayer = { id, type, ...options }; - - setLayers(prev => { - // Find the highest z-index for this type - const sameTypeMax = Math.max( - ...prev - .filter(layer => layer.type === type) - .map(layer => layer.zIndex || 0), - 0 - ); - - // Base z-index depends on layer type - const baseIndex = { - tooltip: 600, - popover: 700, - dropdown: 800, - overlay: 1000, - modal: 1100, - toast: 1200, - alert: 1300 - }[type] || 500; - - // Calculate new z-index - const zIndex = sameTypeMax > 0 - ? sameTypeMax + 10 - : baseIndex; - - return [...prev, { ...newLayer, zIndex }]; - }); - - return { - getInfo: () => { - const currentLayers = layers; - const currentLayer = currentLayers.find(l => l.id === id); - return { - zIndex: currentLayer?.zIndex, - isTopmost: currentLayers.length > 0 && - currentLayers[currentLayers.length - 1].id === id, - isTopmostOfType: currentLayers.filter(l => l.type === type).length > 0 && - currentLayers.filter(l => l.type === type).slice(-1)[0].id === id - }; - } - }; - }, [layers]); - - // Unregister a layer - const unregister = useCallback((id) => { - const index = layers.findIndex(layer => layer.id === id); - if (index >= 0) { - const wasTopmost = index === layers.length - 1; - const type = layers[index].type; - - setLayers(prev => prev.filter(layer => layer.id !== id)); - - // Return the next layer that should receive focus - if (wasTopmost && layers.length > 1) { - // Find the next topmost layer of the same type, or any type if none - const sameTypeLayer = [...layers] - .filter(layer => layer.id !== id && layer.type === type) - .pop(); - - if (sameTypeLayer) return sameTypeLayer.id; - - // Return the overall topmost layer - return layers[layers.length - 2].id; - } - } - return null; - }, [layers]); - - // Get the topmost layer, optionally filtered by type - const getTopmost = useCallback((type) => { - const filteredLayers = type - ? layers.filter(layer => layer.type === type) - : layers; - - return filteredLayers.length > 0 - ? filteredLayers[filteredLayers.length - 1].id - : null; - }, [layers]); - - const value = useMemo(() => ({ - register, - unregister, - getTopmost, - isTopmost: (id, options = {}) => { - const { ofType = false } = options; - const layer = layers.find(layer => layer.id === id); - - if (!layer) return false; - - return ofType - ? getTopmost(layer.type) === id - : getTopmost() === id; - }, - getLayers: () => [...layers] - }), [register, unregister, getTopmost, layers]); - - return ( - - {children} - - ); -}); - -export const Layer = { - Provider, - Context: LayerContext -}; -``` - -**Reusable across:** -- Modals and dialogs -- Tooltips and popovers -- Dropdown menus -- Toast notifications -- Alert messages -- Any stacked UI element - -### @bento/portal - -The Portal primitive handles rendering content at a specific DOM location: - -```jsx -import { createPortal } from 'react-dom'; -import { useEffect, useState } from 'react'; -import { withSlots } from '@bento/slots'; -import { useRenderProps } from '@bento/use-render-props'; - -export const Portal = withSlots('BentoPortal', function Portal(args) { - const [props, apply] = useRenderProps(args); - const { - container = document.body, - children, - whenMounted = true // Only render when component is mounted - } = props; - - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - return () => setMounted(false); - }, []); - - // Don't render anything on the server or if not mounted and whenMounted is true - if ((!mounted && whenMounted) || typeof document === 'undefined') { - return null; - } - - return createPortal(children, container); -}); -``` - -**Reusable across:** -- Modals, dialogs, and drawers -- Tooltips and popovers -- Dropdown menus -- Toasts and notification systems -- Any component that needs to render outside its parent hierarchy - -### @bento/focus-scope - -The FocusScope primitive manages keyboard focus containment: - -```jsx -import { FocusScope as AriaFocusScope } from '@react-aria/focus'; -import { withSlots } from '@bento/slots'; -import { useRenderProps } from '@bento/use-render-props'; - -export const FocusScope = withSlots('BentoFocusScope', function FocusScope(args) { - const [props, apply] = useRenderProps(args); - const { - children, - contain = true, - restoreFocus = true, - autoFocus = true, - ...rest - } = props; - - return ( - - {children} - - ); -}); -``` - -**Reusable across:** -- Modals and dialogs -- Dropdown menus and selects -- Comboboxes -- Navigation menus -- Tab panels -- Any interactive component that needs focus containment - -### @bento/scroll-lock - -The ScrollLock primitive prevents background scrolling: - -```jsx -import { usePreventScroll } from '@react-aria/overlays'; -import { withSlots } from '@bento/slots'; -import { useRenderProps } from '@bento/use-render-props'; - -export const ScrollLock = withSlots('BentoScrollLock', function ScrollLock(args) { - const [props, apply] = useRenderProps(args); - const { - isDisabled = false, - children, - options = {} - } = props; - - // React Aria handles all the scroll locking logic - usePreventScroll({ - isDisabled, - ...options - }); - - return children || null; -}); -``` - -**Reusable across:** -- Modals and dialogs -- Fullscreen menus -- Image viewers and carousels -- Bottom sheets and drawers -- Any component that should prevent background scrolling - -### @bento/visually-hidden - -The VisuallyHidden primitive provides screen reader support without visual elements: - -```jsx -import { withSlots } from '@bento/slots'; -import { useRenderProps } from '@bento/use-render-props'; - -const visuallyHiddenStyles = { - border: 0, - clip: 'rect(0 0 0 0)', - height: '1px', - margin: '-1px', - overflow: 'hidden', - padding: 0, - position: 'absolute', - width: '1px', - whiteSpace: 'nowrap', - wordWrap: 'normal' -}; - -export const VisuallyHidden = withSlots('BentoVisuallyHidden', function VisuallyHidden(args) { - const [props, apply] = useRenderProps(args); - const { as: Component = 'span', children, ...rest } = props; - - return ( - - {children} - - ); -}); -``` - -**Reusable across:** -- Skip links -- Screen reader announcements -- Hidden form labels -- Accessible icons and buttons -- Any content that should be available to screen readers but not visually - -### @bento/dismiss-button - -The DismissButton primitive provides accessible dismissal functionality: - -```jsx -import { DismissButton as AriaDismissButton } from '@react-aria/overlays'; -import { withSlots } from '@bento/slots'; -import { useRenderProps } from '@bento/use-render-props'; - -export const DismissButton = withSlots('BentoDismissButton', function DismissButton(args) { - const [props, apply] = useRenderProps(args); - const { onDismiss, ...rest } = props; - - return ; -}); -``` - -**Reusable across:** -- Modals and dialogs -- Popovers and tooltips -- Toast notifications -- Any dismissible component that needs keyboard accessibility - -## React Aria Integration - -### Why React Aria? - -After extensive research of different overlay implementations, React Aria stands out as the optimal foundation for several reasons: - -1. **Accessibility excellence**: React Aria is built from the ground up with accessibility as the primary focus, ensuring WCAG compliance. - -2. **Comprehensive feature set**: React Aria provides a complete solution for overlay functionality: - - Focus management with `useFocusScope` - - Scroll locking with `usePreventScroll` - - Backdrop interaction with `useModalOverlay` - - Trigger management with `useOverlayTrigger` - -3. **Battle-tested in production**: Used extensively in Adobe's product ecosystem, ensuring robust performance in real-world applications. - -4. **Excellent mobile support**: Handles complex mobile edge cases that other libraries miss, including iOS Safari scroll handling. - -5. **Active maintenance**: Regular updates and community support ensure the library stays current with browser changes and accessibility standards. - -### Comparative Analysis - -We evaluated several libraries for overlay implementation: - -| Library | Strengths | Weaknesses | Focus Management | Multiple Overlay Support | -|---------|-----------|------------|------------------|--------------------------| -| React Aria | Exceptional a11y, robust focus management, strong mobile support | Less composable API | FocusScope component with complete trapping | Overlay container system | -| Radix UI | Highly composable API, excellent animation support | Some mobile edge cases | Dialog-specific focus handling | Built-in dialog stack | -| AriaKit | Flexible API, excellent composition | More complex API | FocusTrap component | Store-based dialog state | -| Headless UI | Simplicity, good animation integration | Less flexible | Dialog-specific focus handling | Limited | -| Material UI | Performance optimizations, comprehensive features | Less headless, more opinionated | Uses custom FocusTrap | ModalManager utility | - -React Aria provides the strongest foundation for accessibility and mobile support, which are top priorities. We'll enhance it with Bento's composition model and additional features from other libraries where appropriate. - -## Multiple Overlay Management - -A key consideration is handling multiple active overlays simultaneously. Our research across implementations revealed several approaches: - -### Stacking and Focus Management Challenges - -When multiple overlays are active, three aspects must be managed: - -1. **Visual stacking**: Which overlay appears on top -2. **Keyboard focus**: Which overlay receives focus -3. **Interaction boundaries**: How interactions propagate between layers - -### ARIA Requirements for Multiple Overlays - -For proper accessibility: -- Only the topmost modal overlay should have `aria-modal="true"` -- Lower overlays should receive `aria-hidden="true"` -- Focus should transfer between overlays in a predictable manner matching the visual hierarchy - -### Implementation Approaches - -Different libraries handle multiple overlays in different ways: - -- **Radix UI**: Uses a global dialog stack to track open dialogs -- **AriaKit**: Uses a dialog store to manage state and explicit ordering -- **React Aria**: Uses overlay containers with built-in stacking -- **MUI**: Uses a dedicated ModalManager to handle multiple modals - -Our `@bento/layer-stack` primitive incorporates the best aspects of these approaches while maintaining full compatibility with React Aria: - -```jsx -function OverlayProvider({ children }) { - // Use React Aria's overlay container as the foundation - const ref = useRef(); - const { overlayContainerRef } = useOverlayContainer(); - - useEffect(() => { - // Connect our ref to React Aria's overlay container - if (ref.current) { - overlayContainerRef.current = ref.current; - } - }, [overlayContainerRef]); - - // Add Bento-specific overlay stack management - const [overlayStack, setOverlayStack] = useState([]); - - // Implementation details... - - return ( - - {children} -
- - ); -} -``` - -## Focus Management - -Focus management is consistently the most critical and complex aspect of overlay implementations. Based on our research, we identified these key requirements: - -1. **Focus trapping**: Prevents tabbing outside the overlay while it's open -2. **Initial focus control**: Allows specifying which element receives focus when opened -3. **Return focus**: Returns focus to the trigger element when closed -4. **Focus stack**: Manages focus between multiple overlays - -React Aria's FocusScope is the most robust solution we evaluated, handling edge cases like: -- Browser extensions that inject focusable elements -- Shadow DOM boundaries -- Dynamic content changes -- Screen reader navigation - -Our `@bento/focus-scope` primitive preserves these capabilities while adapting to Bento's patterns. - -## Animation Support - -Animation is a crucial part of a good overlay experience. Our research identified several approaches: - -1. **CSS-based animations**: Using data attributes for state-driven animations -2. **Component-based animations**: Providing animation components for common patterns -3. **Hook-based animations**: Exposing animation state for custom animations - -Different libraries handle animations in different ways: -- **Radix UI**: Uses data attributes (`data-state`, `data-side`, etc.) for CSS animations -- **Chakra UI**: Provides transition components for common animations -- **Material UI**: Offers dedicated transition components -- **AriaKit**: Supports CSS transitions and library integration - -Our approach leverages data attributes for maximum flexibility: - -```jsx - { - if (state.open && state.animated) return styles.contentAnimateIn; - if (!state.open && state.animated) return styles.contentAnimateOut; - return styles.content; - }} -> - Content with CSS animations - -``` - -## Mobile Considerations - -Mobile devices present unique challenges for overlays: - -1. **Touch interactions**: Mobile-friendly dismissal and interaction patterns -2. **Virtual keyboard handling**: Adjusting for keyboard appearance that may cover content -3. **Safe areas**: Respecting device-specific safe areas (notches, home indicators) -4. **Scroll management**: Preventing background content scroll without breaking overlay scrolling - -Specific issues identified in our research: -- iOS Safari has unique scroll behavior requiring special handling -- Android Chrome can have focus-related scrolling issues -- Virtual keyboards can push content out of view - -React Aria handles most of these issues effectively, particularly with its `usePreventScroll` hook that properly manages iOS Safari edge cases. - -## Scrolling Behavior - -Scroll management is a complex aspect of overlay implementation. Our research revealed several approaches: - -1. **Body overflow technique**: Setting `overflow: hidden` on the document body -2. **Position fixed technique**: Using `position: fixed` with saved scroll position -3. **Inert attribute**: Using the `inert` attribute on background elements (limited browser support) - -React Aria's `usePreventScroll` hook provides the most robust solution: - -```javascript -// Excerpt from React Aria's implementation -useLayoutEffect(() => { - if (!isDisabled && isMounted.current) { - activeCount++; - if (activeCount === 1) { - // Save the initial body style - originalStyles.current = { - overflow: document.body.style.overflow, - paddingRight: document.body.style.paddingRight - }; - - // Get the scrollbar width - let scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - - // Add padding to prevent layout shift - if (scrollbarWidth > 0) { - document.body.style.paddingRight = `${scrollbarWidth}px`; - } - - // Prevent scrolling - document.body.style.overflow = 'hidden'; - } - - return () => { - activeCount--; - if (activeCount === 0) { - // Restore the initial body style - document.body.style.overflow = originalStyles.current.overflow; - document.body.style.paddingRight = originalStyles.current.paddingRight; - } - }; - } -}, [isDisabled]); -``` - -## Performance Considerations - -Performance is critical for overlays, especially on mobile devices. Our research identified several optimization opportunities: - -1. **Conditional rendering**: Only mount components when they're visible -2. **Memoization**: Use React.memo and useMemo for complex overlay content -3. **Animation optimization**: Animate only transform and opacity properties when possible -4. **Lazy loading**: Support code-splitting for heavy overlay content -5. **Cleanup**: Ensure proper removal of event listeners and cleanup of effects - -Implementation examples for performance optimization: - -```jsx -// Conditional rendering -function OptimizedOverlay() { - const [open, setOpen] = useState(false); - - // Only render overlay content when open - return ( - <> - - {open && ( - - - {/* Content here */} - - - )} - - ); -} - -// Memoization for expensive content -const MemoizedOverlayContent = React.memo(function OverlayContent() { - // Complex content here - return
{/* ... */}
; -}); -``` - -## Accessibility Highlights - -The Overlay primitive inherits React Aria's excellent accessibility features: - -- **ARIA roles:** Automatically applies `role="dialog"` or `role="alertdialog"` when appropriate -- **Modal state:** Uses `aria-modal="true"` for modal overlays -- **Focus management:** Implements proper focus trapping via the FocusScope primitive -- **Keyboard support:** - - Escape key to dismiss - - Tab navigation contained within the overlay - - Return focus to trigger element on close -- **Screen reader announcements** for overlay state changes -- **Proper handling of focus order** with multiple overlays - -For mobile accessibility specifically: -- Touch targets follow accessibility guidelines (minimum 44×44px) -- Screen reader gestures are properly supported -- Visible focus indicators are maintained for keyboard users - -## Internationalization, RTL, and Mobile Considerations - -The Overlay primitive is designed to work across different languages and layouts: - -- **RTL support:** The overlay positioning and animations work correctly in right-to-left layouts -- **Mobile considerations:** - - Larger touch targets for mobile interactions - - Special handling for iOS Safari to prevent background content scrolling - - Support for mobile gestures (swipe to dismiss) - - Adjustments for virtual keyboard appearance - - Responsive sizing and positioning - -- **Internationalization:** The component handles text expansion from different languages by using flexible layouts - -## Data Attributes and Slot Map - -### Expected `data-*` Attributes - -| Attribute | Description | Example Values | -|----------------------|-----------------------------------------------|------------------------| -| `data-state` | Current state of the overlay | "open", "closed" | -| `data-open` | Whether the overlay is open | "true", "false" | -| `data-modal` | Whether the overlay blocks interactions | "true", "false" | -| `data-animated` | Whether the overlay is animated | "true", "false" | -| `data-placement` | Positioning of the overlay | "center", "top", etc. | -| `data-override` | Indicates customized behavior | "style", "className" | -| `data-version` | Component version (dev only) | "overlay@1.0" | -| `data-top-most` | Whether overlay is the topmost in stack | "true", "false" | - -### Slot Map - -| Slot Name | Description | Required | Default Fallback | -|--------------|---------------------------------------|----------|------------------| -| `root` | Top-level overlay container | Yes | Yes | -| `backdrop` | Background layer behind content | No | Yes | -| `content` | Container for overlay content | Yes | Yes | -| `trigger` | Element that opens the overlay | No | No | -| `close` | Close button/trigger | No | No | - -## Code Examples - -### Basic usage - -```jsx -import { Overlay } from '@bento/overlay'; -import { Layer } from '@bento/layer-stack'; - -function BasicModal() { - const [open, setOpen] = useState(false); - - return ( - - - - - - -

Basic Overlay

-

This is a simple overlay with backdrop and content.

- Close -
-
-
- ); -} -``` - -### Multiple overlays demonstrating stacking - -```jsx -function MultipleOverlays() { - const [firstOpen, setFirstOpen] = useState(false); - const [secondOpen, setSecondOpen] = useState(false); - - return ( - - - - - -

First Overlay

- - Close First -
-
- - - -

Second Overlay

-

This overlay appears above the first one.

- Close Second -
-
-
- ); -} -``` - -### Animation with render props - -```jsx -function AnimatedOverlay() { - return ( - - Open animated overlay - - - state.open ? styles.backdropOpen : styles.backdropClosed - } - /> - - { - if (state.open && state.animated) return styles.contentAnimateIn; - if (!state.open && state.animated) return styles.contentAnimateOut; - return styles.content; - }} - > -

Animated Overlay

-

This overlay has custom enter/exit animations.

- Close -
-
- ); -} -``` - -### Non-modal overlay with custom positioning - -```jsx -function PositionedOverlay() { - return ( - - Open positioned overlay - - -

This overlay is positioned relative to its trigger.

- Close -
-
- ); -} -``` - -### Toast system using the same primitives - -```jsx -function ToastSystem() { - const { toasts, removeToast } = useToasts(); - const layerStack = useLayerStack(); - - return ( - -
- {toasts.map(toast => { - // Register each toast with the layer system - const layer = layerStack.register(toast.id, { type: 'toast' }); - - return ( -
- {toast.message} - - {/* Use VisuallyHidden for screen readers */} - - {toast.isError ? 'Error notification' : 'Notification'} - -
- ); - })} -
-
- ); -} -``` - -## Competitive Research - -We conducted comprehensive research across major component libraries to identify the best approaches to overlay implementation: - -### React Aria - -React Aria provides a robust set of hooks for building accessible overlays: - -- **useOverlay**: Manages ARIA attributes and keyboard interactions -- **useModalOverlay**: Adds modal-specific behaviors like backdrop interaction -- **useFocusScope**: Handles focus containment -- **usePreventScroll**: Manages scroll locking with excellent cross-browser support -- **useOverlayPosition**: Supports positioning relative to trigger elements -- **useOverlayTrigger**: Connects trigger elements to overlays - -React Aria excels at accessibility but has a more imperative API that requires additional work to fit into Bento's composable component model. - -### Radix UI - -Radix UI offers a highly composable dialog component: - -```jsx -// Radix UI approach - - - - - - - - - - - -``` - -Strengths: -- Excellent composition model -- Good animation support via data attributes -- Solid accessibility - -Weaknesses: -- Some mobile edge cases -- Occasional focus management issues in complex scenarios - -### AriaKit - -AriaKit provides a flexible, store-based approach: - -```jsx -// AriaKit approach -const dialog = useDialogStore(); - - - Title - Description -
Content
- Close -
-``` - -Strengths: -- Powerful, flexible API -- Excellent composition -- Strong accessibility - -Weaknesses: -- More complex API with steeper learning curve -- Less direct React Aria integration - -### Headless UI - -Headless UI offers a simpler approach with good animation integration: - -```jsx -// Headless UI approach - - - - Title - Description -
Content
-
-
-``` - -Strengths: -- Simplicity -- Good animation integration -- Solid accessibility - -Weaknesses: -- Less flexible -- Fewer customization options - -### Material UI - -Material UI provides a comprehensive modal implementation: - -```jsx -// Material UI approach - - - - - - -``` - -Strengths: -- Comprehensive features -- Good performance optimizations -- Built-in transitions - -Weaknesses: -- More opinionated -- Less headless -- Less compositional - -## UX Best Practices for Multiple Overlays - -From a user experience perspective, multiple active overlays require careful consideration: - -1. **Visual hierarchy**: Each new overlay should clearly appear above previous ones -2. **Contextual relationship**: Related overlays should maintain visual connection -3. **Consistent dismissal pattern**: Users should be able to dismiss overlays in reverse order of appearance - -Our research identified several effective patterns: - -- **Overlay groups**: Supporting parent-child relationships between overlays -- **Contextual positioning**: Positioning new overlays relative to their trigger elements -- **Backdrop variation**: Using different backdrop opacities or colors to indicate hierarchy -- **Animation coordination**: Coordinating enter/exit animations between overlays - -These patterns can be implemented using our proposed primitives: - -```jsx -// Managing parent-child overlay relationships - - Open parent - - - Open child - - Child overlay content - - - - -``` - -## Animation Capabilities - -All modern overlay implementations support animations, with varying approaches: - -- **CSS-based**: Using data attributes for state-driven animations -- **Component-based**: Providing animation components for common patterns -- **Hook-based**: Exposing animation state for custom animation - -Our approach leverages data attributes for maximum flexibility while supporting integration with animation libraries: - -```css -/* CSS-based animation example */ -.overlay[data-state="open"] { - animation: fadeIn 200ms ease; -} - -.overlay[data-state="closed"] { - animation: fadeOut 200ms ease; -} -``` - -This approach works well with: -- Pure CSS animations -- CSS-in-JS libraries -- Animation libraries like Framer Motion - -## Z-index Management - -Stacking context and z-index management require a systematic approach, which our LayerStack primitive provides: - -- **Established z-index scale**: Predefined ranges for different UI layer types -- **Dynamic z-index calculation**: Based on layer type and existing layers -- **Proper stacking of multiple layers**: Ensuring correct visual hierarchy -- **Integration with React Aria's overlay container**: For maximum compatibility - -The LayerStack primitive handles these concerns automatically, providing a consistent approach across all Bento components. - -## Conclusion - -By creating a suite of generalized primitives as individual packages, the Bento Overlay component can leverage reusable building blocks that serve multiple UI patterns. This approach: - -1. **Maximizes reusability** across the component system -2. **Follows Bento's established architecture** of separate primitive packages -3. **Leverages React Aria** for best-in-class accessibility -4. **Enables consistent behavior** across different UI patterns -5. **Promotes compositional flexibility** for developers - -The resulting Overlay component will be built on a solid foundation of primitives that can be used and maintained independently, making the entire Bento system more robust, consistent, and developer-friendly. diff --git a/docs/pdrs/overlay-claude.mdx b/docs/pdrs/overlay-claude.mdx deleted file mode 100644 index c72b5fc67..000000000 --- a/docs/pdrs/overlay-claude.mdx +++ /dev/null @@ -1,1571 +0,0 @@ -# Overlay Component PDR - -## Purpose - -The Overlay component serves as the primary building block for creating layered UI experiences where content appears above the main application view. It provides a complete, accessible foundation for modal dialogs, drawers, fullscreen overlays, and other patterns requiring content to appear on top of the page. - -**For micro-frontend architectures**, Overlay integrates with Layer Orchestration (see `layer-orchestration.mdx`) to coordinate layers across independent JavaScript bundles **without requiring a host application**. Using a DOM-based registry, MFEs self-organize to ensure proper z-index management, priority resolution, and ARIA coordination when multiple MFEs compete for screen space. - -The Overlay component addresses several key scenarios: - -- **Modal dialogs**: Displaying content that requires immediate user attention -- **Drawers and sheets**: Side panels or bottom sheets common in mobile interfaces -- **Loading states**: Fullscreen loading indicators that block interaction -- **Image viewers**: Lightbox-style image viewing experiences -- **Onboarding flows**: Step-by-step tutorials that overlay the application - -This component will be consumed by: -- Application developers building modal interfaces -- Design system teams creating specialized dialog components -- Product teams implementing mobile-first drawer patterns -- Any UI pattern requiring layered content with focus management - -### Unique Attributes - -The Overlay component differs from simple portal+backdrop combinations in these key ways: - -- **Complete accessibility**: Built on React Aria's `useModalOverlay` with proper ARIA attributes and focus management -- **Composable architecture**: Combines Portal, Underlay, and FocusLock primitives through Bento's slot system -- **Flexible rendering**: Supports both modal (blocking) and non-modal (non-blocking) overlays -- **Scroll management**: Integrates with React Aria's `usePreventScroll` to handle background scrolling -- **Mobile-optimized**: Handles iOS Safari scroll quirks and mobile gesture patterns -- **Bento patterns**: Built with `withSlots`, `useProps`, and `useDataAttributes` for ecosystem consistency - -## Primitive Composition - -The Overlay component composes several Bento primitives and systems: - -1. **`Portal`** (@bento/portal): Renders overlay content outside parent DOM hierarchy -2. **`Underlay`** (@bento/underlay): Provides backdrop with click-to-dismiss behavior -3. **`FocusLock`** (@bento/focus-lock): Manages focus containment within overlay -4. **`Container`** (@bento/container): Polymorphic container for overlay content -5. **`useLayerOrchestration`** (@bento/layer-orchestration): Registers layer in DOM registry for multi-MFE coordination (optional) -6. **`useModalOverlay`** (React Aria): Provides modal overlay behavior and accessibility -7. **`useModal`** (React Aria): Manages aria-hidden coordination -8. **`usePreventScroll`** (React Aria): Prevents background scrolling when overlay is open -9. **`useDialog`** (React Aria): Provides dialog ARIA attributes when needed -10. **`useOverlayTriggerState`** (React Aria): State management for overlays -11. **`withSlots`**: Bento's slot system for composition -12. **`useProps`**: Bento's prop handling with state exposure -13. **`useDataAttributes`**: Exposes state as data attributes - -### Implementation - -```tsx -import { useRef } from 'react'; -import { useModalOverlay, useDialog, usePreventScroll, useModal } from '@react-aria/overlays'; -import { useOverlayTriggerState } from '@react-stately/overlays'; -import { withSlots, type Slots } from '@bento/slots'; -import { useProps } from '@bento/use-props'; -import { useDataAttributes } from '@bento/use-data-attributes'; -import { Portal } from '@bento/portal'; -import { Underlay } from '@bento/underlay'; -import { FocusLock } from '@bento/focus-lock'; -import { Container } from '@bento/container'; - -export interface OverlayProps extends Slots { - /** - * Micro-frontend identifier for cross-bundle coordination. - * Required when using Layer Orchestrator. - * @example 'checkout-mfe', 'legal-compliance', 'marketing' - */ - mfeId?: string; - - /** - * Priority level for layer orchestration. - * Determines z-index and blocking behavior in multi-MFE scenarios. - * @default 'normal' - */ - priority?: 'critical' | 'high' | 'normal' | 'low'; - - /** - * Whether the overlay is open. - * Controlled prop - component is fully controlled when provided. - */ - isOpen?: boolean; - - /** - * Default open state for uncontrolled usage. - * @default false - */ - defaultOpen?: boolean; - - /** - * Called when the overlay's open state changes. - */ - onOpenChange?: (isOpen: boolean) => void; - - /** - * Whether the overlay is modal (blocking). - * @default true - */ - isModal?: boolean; - - /** - * Whether the overlay can be dismissed. - * @default true - */ - isDismissable?: boolean; - - shouldCloseOnInteractOutside?: boolean; - shouldCloseOnEscape?: boolean; - portalContainer?: Element | DocumentFragment; - shouldBlockScroll?: boolean; - shouldRestoreFocus?: boolean; - shouldAutoFocus?: boolean; - shouldContainFocus?: boolean; - role?: 'dialog' | 'alertdialog' | 'menu' | 'listbox'; - 'aria-labelledby'?: string; - 'aria-describedby'?: string; - 'aria-label'?: string; - children?: React.ReactNode; - className?: string | ((state: OverlayState) => string); - style?: React.CSSProperties | ((state: OverlayState) => React.CSSProperties); -} - -export interface OverlayState { - isOpen: boolean; - isModal: boolean; - isDismissable: boolean; -} - -export const Overlay = withSlots('BentoOverlay', function Overlay(args: OverlayProps) { - const { - mfeId, // Micro-frontend ID for orchestration - priority = 'normal', // Priority level - isModal = true, - isDismissable = true, - shouldCloseOnInteractOutside = isDismissable, - shouldCloseOnEscape = isDismissable, - shouldBlockScroll = isModal, - shouldRestoreFocus = true, - shouldAutoFocus = true, - shouldContainFocus = isModal, - portalContainer, - role = 'dialog', - ...restArgs - } = args; - - const ref = useRef(null); - - // State management - const state = useOverlayTriggerState(args); - const { isOpen } = state; - - // Register with Layer Orchestration (if mfeId provided) - const orchestration = mfeId - ? useLayerOrchestration({ - type: 'overlay', - priority, - mfeId, - isOpen - }) - : null; - - // Use orchestrated z-index/ARIA if available - const orchestratedZIndex = orchestration?.zIndex; - const orchestratedAriaHidden = orchestration?.ariaHidden; - const orchestratedAriaModal = orchestration?.ariaModal; - - // Modal overlay behavior (React ARIA) - const { modalProps, underlayProps } = useModalOverlay( - { - isOpen, - onClose: () => state.setOpen(false), - isDismissable, - shouldCloseOnInteractOutside - }, - state, - ref - ); - - // useModal manages aria-hidden for single-tree coordination - // Layer orchestration overrides this for multi-MFE scenarios - useModal({ - isDisabled: !isModal || !isOpen || !!orchestration - }); - - // Dialog ARIA attributes - const { dialogProps } = useDialog( - { - role, - 'aria-labelledby': args['aria-labelledby'], - 'aria-describedby': args['aria-describedby'], - 'aria-label': args['aria-label'] - }, - ref - ); - - // Prevent background scroll - usePreventScroll({ isDisabled: !shouldBlockScroll || !isOpen }); - - // Process props with state - const { props, apply } = useProps(restArgs, { - isOpen, - isModal, - isDismissable, - priority - }); - - // Data attributes - const dataAttributes = useDataAttributes({ - open: isOpen, - modal: isModal, - dismissable: isDismissable, - priority - }); - - if (!isOpen) return null; - - return ( - - {isModal && ( - state.setOpen(false)} - slot="underlay" - style={{ - zIndex: orchestratedZIndex ? orchestratedZIndex - 1 : undefined - }} - /> - )} - - - - {props.children} - - - - ); -}); -``` - -**Key Points:** - -1. **Layer Orchestration Integration**: When `mfeId` is provided, registers layer in DOM registry for cross-MFE coordination -2. **Priority-based stacking**: Uses numeric `priority` for version-agnostic coordination -3. **Orchestrated z-index**: Calculated from DOM registry by all bundles -4. **Orchestrated ARIA**: Calculated from DOM registry for multi-MFE scenarios -5. **React ARIA fallback**: When no `mfeId`, uses React ARIA's OverlayProvider pattern -6. **Backward compatible**: Works with or without orchestration -7. **No host needed**: MFEs self-organize via DOM without host application - -## Implementation Patterns - -| Concept | Implementation | -|---------------------|--------------------------------------------------------------| -| React Aria | useModalOverlay, usePreventScroll, useDialog, useOverlayTrigger | -| Slots | withSlots for customizing underlay, content, and structure | -| Render Props | className and style as functions with state object | -| data-* Attributes | data-open, data-modal, data-dismissable | -| data-version | Dev-only attribute for version tracking | -| Composability | Supports nested overlays, custom containers, slot overrides | - -## Internal Structure & Reuse Potential - -The Overlay component is designed as a coordinator that brings together focused primitives: - -``` -Overlay (state management, coordination) -├─ Portal (DOM rendering location) -│ ├─ Underlay (backdrop, click-to-dismiss) -│ └─ FocusLock (focus containment) -│ └─ Container (overlay content) -│ └─ children (user content) -``` - -Each piece can be used independently: - -- **Portal**: Use alone for tooltips, popovers (no backdrop needed) -- **Underlay**: Use with custom overlay implementations -- **FocusLock**: Use for focus trapping without full overlay pattern -- **Overlay**: Use as complete solution for modal experiences - -This separation enables: -- High reuse of primitives across different patterns -- Clear separation of concerns (rendering, focus, backdrop, content) -- Flexibility to compose custom overlay experiences -- Minimal, focused primitive implementations - -## React Aria Integration - -The Overlay component leverages multiple React Aria hooks: - -### useModalOverlay - -Provides core overlay behavior: - -```tsx -const { modalProps, underlayProps } = useModalOverlay(props, state, ref); -``` - -Benefits: -- ARIA attributes for modal overlays (`aria-modal="true"`) -- Click-outside-to-dismiss behavior -- Escape key handling -- Proper event handling across devices - -### usePreventScroll - -Prevents background scrolling while overlay is open: - -```tsx -usePreventScroll({ isDisabled: !isOpen }); -``` - -Benefits: -- Cross-browser scroll prevention -- Handles iOS Safari scroll quirks -- Preserves scroll position -- Accounts for scrollbar width to prevent layout shift - -### useDialog (optional) - -Provides dialog-specific ARIA attributes when overlay contains a dialog: - -```tsx -const { dialogProps, titleProps } = useDialog(props, ref); -``` - -Benefits: -- Proper `role="dialog"` or `role="alertdialog"` -- Associates title with dialog via `aria-labelledby` -- Associates description via `aria-describedby` - -### useOverlayTrigger (for parent integration) - -Not used directly by Overlay, but provided for components that trigger overlays: - -```tsx -const { triggerProps, overlayProps } = useOverlayTrigger(props, state); -``` - -This is exported for consumers building trigger+overlay patterns. - -## Architecture & Features - -### Core Behavior - -The Overlay component: - -1. **Manages open/closed state**: Controlled or uncontrolled -2. **Renders via portal**: Content appears at document.body (customizable) -3. **Shows backdrop**: Optional underlay with click-to-dismiss -4. **Locks focus**: Traps keyboard focus within overlay content -5. **Prevents scroll**: Locks background scrolling while open -6. **Handles dismissal**: Escape key, outside click, programmatic close -7. **Exposes state**: Via data attributes and render props - -### API - -```tsx -interface OverlayProps extends Slots { - /** - * Whether the overlay is open. - * Controlled prop - component is fully controlled when provided. - */ - isOpen?: boolean; - - /** - * Default open state for uncontrolled usage. - * @default false - */ - defaultOpen?: boolean; - - /** - * Called when the overlay's open state changes. - */ - onOpenChange?: (isOpen: boolean) => void; - - /** - * Whether the overlay is modal (blocking). - * Modal overlays: - * - Show a backdrop/underlay - * - Prevent background interaction - * - Lock focus within overlay - * - Prevent background scrolling - * - * Non-modal overlays: - * - No backdrop - * - Allow background interaction - * - Focus not locked (returns to trigger on Tab out) - * - * @default true - */ - isModal?: boolean; - - /** - * Whether the overlay can be dismissed by: - * - Clicking the backdrop/underlay - * - Pressing Escape key - * - * @default true - */ - isDismissable?: boolean; - - /** - * Whether to close the overlay when clicking outside. - * Only applies when isDismissable is true. - * @default true (when isDismissable is true) - */ - shouldCloseOnInteractOutside?: boolean; - - /** - * Whether pressing Escape should close the overlay. - * Only applies when isDismissable is true. - * @default true (when isDismissable is true) - */ - shouldCloseOnEscape?: boolean; - - /** - * The container element to portal into. - * @default document.body - */ - portalContainer?: Element | DocumentFragment; - - /** - * Whether to prevent scrolling on the body while overlay is open. - * @default true (when isModal is true) - */ - shouldBlockScroll?: boolean; - - /** - * Whether to restore focus to the trigger element when overlay closes. - * @default true - */ - shouldRestoreFocus?: boolean; - - /** - * Whether to auto-focus the first focusable element when overlay opens. - * @default true - */ - shouldAutoFocus?: boolean; - - /** - * Whether to contain focus within the overlay. - * @default true (when isModal is true) - */ - shouldContainFocus?: boolean; - - /** - * ARIA role for the overlay content. - * @default 'dialog' - */ - role?: 'dialog' | 'alertdialog' | 'menu' | 'listbox'; - - /** - * ID of the element that labels the overlay. - */ - 'aria-labelledby'?: string; - - /** - * ID of the element that describes the overlay. - */ - 'aria-describedby'?: string; - - /** - * Accessible label for the overlay when aria-labelledby is not provided. - */ - 'aria-label'?: string; - - /** - * The content to render inside the overlay. - */ - children?: React.ReactNode; - - /** - * Custom className for the overlay content. - * Can be a string or render prop receiving state. - */ - className?: string | ((state: OverlayState) => string); - - /** - * Custom styles for the overlay content. - * Can be an object or render prop receiving state. - */ - style?: React.CSSProperties | ((state: OverlayState) => React.CSSProperties); -} - -interface OverlayState { - /** Whether the overlay is currently open */ - isOpen: boolean; - /** Whether the overlay is modal (blocking) */ - isModal: boolean; - /** Whether the overlay can be dismissed */ - isDismissable: boolean; -} -``` - -### Rendered DOM Structure - -```html - -
- -
- - -
- - -
-
-``` - -### Controlled vs Uncontrolled - -```tsx -import { Overlay } from '@bento/overlay'; -import { Container } from '@bento/container'; -import { Heading } from '@bento/heading'; -import { Button } from '@bento/button'; -import { Text } from '@bento/text'; -import { useState } from 'react'; - -// Controlled - component manages state -function ControlledExample() { - const [isOpen, setIsOpen] = useState(false); - - return ( - - - - - Modal Content - - - - - ); -} - -// Uncontrolled - Overlay manages state internally -function UncontrolledExample() { - return ( - - {({ close }) => ( - - Modal Content - - - )} - - ); -} -``` - -### Slot Map - -| Slot Name | Description | Required | Default Fallback | -|--------------|--------------------------------------|----------|------------------| -| `underlay` | Backdrop/underlay element | No | Yes (when modal) | -| `content` | Main overlay content container | No | Yes | -| `focus-lock` | Focus containment wrapper | No | Yes (when modal) | - -### State Management - -The Overlay uses React Aria's overlay state management pattern: - -```tsx -// Internal state management -const state = useOverlayTriggerState(props); -const { isOpen } = state; -``` - -This provides: -- Controlled/uncontrolled state handling -- Open/close methods -- State synchronization -- Event handler composition - -### Modal vs Non-Modal - -**Modal overlays** (`isModal={true}`, default): -- Show backdrop/underlay -- Lock focus within overlay -- Prevent background scrolling -- Block background interaction -- Apply `aria-modal="true"` - -**Non-modal overlays** (`isModal={false}`): -- No backdrop -- Focus not locked (can tab to background) -- Background scrolling allowed -- Background remains interactive -- No `aria-modal` attribute - -Use cases: -- Modal: Dialogs requiring user action, critical alerts -- Non-modal: Tooltips, popovers, dropdown menus - -## Accessibility Highlights - -The Overlay component provides comprehensive accessibility: - -### ARIA Attributes - -- **`role`**: Defaults to `"dialog"`, can be `"alertdialog"`, `"menu"`, `"listbox"` -- **`aria-modal`**: Set to `"true"` for modal overlays -- **`aria-labelledby`**: Associates overlay with labeling element -- **`aria-describedby`**: Associates overlay with describing element -- **`aria-label`**: Direct label when no labelledby element exists - -### Focus Management - -- **Auto-focus**: First focusable element receives focus on open -- **Focus containment**: Tab/Shift+Tab cycle within overlay (modal only) -- **Focus restoration**: Focus returns to trigger element on close -- **Focus visible**: Keyboard focus indicators shown appropriately - -### Keyboard Support - -- **Escape**: Closes dismissable overlays -- **Tab**: Cycles focus within modal overlays -- **Shift+Tab**: Reverse cycles focus within modal overlays - -### Screen Reader Support - -- **Role announcement**: Screen readers announce dialog role -- **Modal state**: `aria-modal` hides background from screen readers -- **Labeling**: Proper label associations for context -- **Dismissal**: Screen readers can access close mechanisms - -## Internationalization, RTL, and Mobile Considerations - -### RTL Support - -The Overlay is direction-agnostic: -- Uses logical properties for layout (`inset`, flexbox) -- Portal rendering is direction-independent -- Content direction handled by children - -### Mobile Considerations - -#### Touch Interactions - -- **Touch-to-dismiss**: Underlay responds to touch events -- **Swipe gestures**: Can be added via custom handlers -- **Pull-to-close**: Supported via custom implementations - -#### iOS Safari Handling - -React Aria's `usePreventScroll` handles iOS Safari quirks: -- Prevents rubber-band scrolling -- Maintains scroll position -- Handles virtual keyboard appearance - -#### Safe Areas - -Overlays respect device safe areas via CSS: - -```tsx - - {/* content */} - -``` - -#### Mobile Patterns - -Common mobile overlay patterns: - -- **Bottom sheets**: Overlay from bottom edge -- **Drawers**: Overlay from side edges -- **Fullscreen modals**: Cover entire viewport - -All supported via styling and slot customization. - -### Internationalization - -- No text content in component itself -- ARIA labels provided by consumers in appropriate locale -- Content direction handled by consumers - -## Data Attributes and Slot Map - -### Expected `data-*` Attributes - -| Attribute | Description | Example Values | -|----------------------|--------------------------------------|-------------------------------------| -| `data-open` | Whether overlay is open | "true" / "false" | -| `data-modal` | Whether overlay is modal | "true" / "false" | -| `data-dismissable` | Whether overlay is dismissable | "true" / "false" | -| `data-priority` | Priority level (with orchestrator) | "critical" / "high" / "normal" / "low" | -| `data-version` | Component version (dev only) | "overlay@1.0" | - -### Slot Map - -| Slot Name | Description | Required | Default Fallback | -|--------------|-----------------------------------------|----------|------------------| -| `underlay` | Backdrop/underlay props | No | Yes (modal only) | -| `content` | Main overlay content container props | No | Yes | -| `focus-lock` | Focus containment wrapper props | No | Yes (modal only) | - -## Competitive Research - -### React Aria - -React Aria provides `useModalOverlay` and related hooks but no component. Our Overlay wraps these hooks in a complete, composable component. - -**Key learnings**: -- Separation of modal vs non-modal is essential -- Focus management is complex and benefits from proven implementation -- Scroll prevention needs special handling for iOS - -**Reference**: [React Aria useModalOverlay](https://react-spectrum.adobe.com/react-aria/useModalOverlay.html) - -### Radix UI Dialog - -Radix provides a comprehensive Dialog primitive with excellent composition: - -```tsx - - - - - - - - - - -``` - -**Key learnings**: -- Explicit Portal component gives control over rendering -- Separate Overlay (backdrop) from Content is clearer -- Trigger integration is valuable for common patterns - -**Differences**: -- Radix uses compound components, Bento uses slots -- Radix more opinionated about structure -- Bento more flexible for custom compositions - -**Reference**: [Radix Dialog](https://www.radix-ui.com/primitives/docs/components/dialog) - -### Headless UI Dialog - -Headless UI provides a simpler Dialog component: - -```tsx - - - - - - - -``` - -**Key learnings**: -- Simplicity is valuable for common cases -- Overlay and Panel separation is intuitive -- Close handling through onClose prop is straightforward - -**Differences**: -- Headless UI less flexible for advanced composition -- Bento provides more control via slots - -**Reference**: [Headless UI Dialog](https://headlessui.com/react/dialog) - -### Chakra UI Modal - -Chakra provides a styled Modal component: - -```tsx - - - - - - - - -``` - -**Key learnings**: -- Separate overlay from content container -- Header/Body/Footer structure for common layouts -- Size variants for different use cases - -**Differences**: -- Chakra includes styling, Bento is headless -- Chakra more prescriptive about structure - -**Reference**: [Chakra Modal](https://chakra-ui.com/docs/components/modal) - -### Material UI Dialog - -Material UI provides a comprehensive Dialog: - -```tsx - - - - - - - -``` - -**Key learnings**: -- Structured content areas (Title, Content, Actions) -- Transition support built-in -- Various sizing modes - -**Differences**: -- Material UI very opinionated about styling -- Bento more primitive and flexible - -**Reference**: [Material UI Dialog](https://mui.com/material-ui/react-dialog/) - -### Ark UI Dialog - -Ark UI (Park UI's headless library) provides similar patterns to Radix: - -**Key learnings**: -- Headless approach aligns with Bento -- Composition via explicit components -- Strong accessibility foundation - -**Reference**: [Ark UI Dialog](https://ark-ui.com/react/docs/components/dialog) - -## Multiple Overlays and Stacking - -### The Problem - -Complex applications frequently need to handle multiple overlays simultaneously: - -1. **Nested modals**: A confirmation dialog opens from within a settings modal -2. **Micro-frontend scenarios**: Multiple independent widgets each opening overlays -3. **Sequential workflows**: Step-by-step wizards with modal confirmations -4. **Multiple concurrent overlays**: Chat window + notification + modal - -### React ARIA's Solution: OverlayProvider - -React ARIA provides `OverlayProvider` to manage multiple overlays **within a single React tree/JavaScript bundle**. This works well for single applications but **has limitations for micro-frontends**. - -**Limitations with MFEs:** -- Only works within single React tree -- Cannot coordinate across independent JavaScript bundles -- MFEs can't share OverlayProvider context -- Z-index conflicts between MFEs - -### Layer Orchestration: Cross-MFE Solution - -For micro-frontend architectures, use **Layer Orchestration** (see `layer-orchestration.mdx`). This is the **recommended approach for multi-MFE applications**. - -**Key Innovation:** -- **No host required**: MFEs self-organize via DOM registry -- **Version-agnostic**: Works across Bento v1, v2, v3, etc. -- **Simple protocol**: DOM attributes as source of truth -- **No leader election**: All bundles are equal - -#### How It Works - -```tsx -import { OverlayProvider } from '@react-aria/overlays'; -import { Overlay } from '@bento/overlay'; - -function App() { - return ( - - - {/* All Overlays automatically coordinate */} - - - - - ); -} -``` - -**OverlayProvider manages:** -- **Automatic z-index**: Each overlay gets progressively higher z-index -- **Focus stack**: Only topmost overlay receives focus -- **ARIA coordination**: Topmost overlay gets `aria-modal="true"`, others get `aria-hidden="true"` -- **Shared container**: All overlays portal to the same container - -### Stacking Behavior - -When multiple overlays are open: - -**Visual Stack (Top → Bottom):** -``` -Inner Modal (z-index: 1020, aria-modal="true") -Inner Modal Backdrop -Outer Modal (z-index: 1010, aria-hidden="true") -Outer Modal Backdrop -Application (aria-hidden="true" when any modal is open) -``` - -**Focus Management:** -1. Focus moves to newest overlay automatically -2. Escape key closes topmost overlay -3. When overlay closes, focus returns to previous overlay (if nested) or trigger element - -**ARIA Attributes:** -- Only topmost modal has `aria-modal="true"` -- Lower modals have `aria-hidden="true"` -- Application content has `aria-hidden="true"` when any modal is open - -### Backdrop Management - -**Bento uses multiple backdrops** (one per overlay) for clearer visual hierarchy: - -```tsx -// Each overlay has its own backdrop - - {/* Has its own backdrop */} - - - - {/* Has its own backdrop on top */} - -``` - -**Benefits:** -- Shows depth through stacking -- Each overlay can customize backdrop appearance -- Matches user expectations from modern UI libraries - -### Nested Overlays Example - -```tsx -import { OverlayProvider } from '@react-aria/overlays'; -import { Overlay } from '@bento/overlay'; -import { Container } from '@bento/container'; -import { Heading } from '@bento/heading'; -import { Text } from '@bento/text'; -import { Button } from '@bento/button'; -import { useState } from 'react'; - -function App() { - return ( - - - - ); -} - -function MultiOverlayExample() { - const [settingsOpen, setSettingsOpen] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); - - return ( - - - - {/* Settings Modal */} - - - - Settings - - Adjust your preferences - - - - - - {/* Confirmation Modal (nested) */} - - - - Confirm Deletion - - This action cannot be undone. Continue? - - - - - - - - - - - ); -} -``` - -**Behavior:** -1. Opening confirmation modal: Settings modal backdrop visible, confirmation modal backdrop on top -2. Focus: Automatically moves to confirmation modal -3. Escape: Closes confirmation modal, focus returns to settings modal -4. ARIA: Confirmation has `aria-modal="true"`, settings has `aria-hidden="true"` - -### Micro-Frontend Integration - -For micro-frontend architectures, wrap the shell in `OverlayProvider`: - -```tsx -// Shell application -import { OverlayProvider } from '@react-aria/overlays'; - -function Shell() { - return ( - -
- - -