diff --git a/README.md b/README.md index b76a7e5b..cd74b877 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Please reference [PatternFly's AI-assisted development guidelines](https://githu ### Before adding a new component: - make sure your use case is new/complex enough to be added to this extension -- the component should bring a value value above and beyond existing PatternFly components +- the component should bring a value above and beyond existing PatternFly components ### To add a new component: @@ -40,7 +40,7 @@ Please reference [PatternFly's AI-assisted development guidelines](https://githu #### Example component: -``` +``` TSX import * as React from 'react'; import { Content } from '@patternfly/react-core'; import { createUseStyles } from 'react-jss'; @@ -75,7 +75,7 @@ export default MyComponent; #### Index file example: -``` +``` TSX export { default } from './MyComponent'; export * from './MyComponent'; ``` @@ -99,8 +99,7 @@ src #### Component API definition example: -``` - +``` TSX import { FunctionComponent } from 'react'; // when possible, extend available PatternFly types @@ -113,7 +112,7 @@ export const MyComponent: FunctionComponent = ({ customLabel, #### Markdown file example: -```` +```` MDX --- section: Component groups subsection: My component's category @@ -135,7 +134,7 @@ MyComponent has been created to demo contributing to this repository. #### Component usage file example: (`MyComponentExample.tsx`) -``` +``` TSX import { FunctionComponent } from 'react'; const MyComponentExample: FunctionComponent = () => ( diff --git a/packages/module/generate-fed-package-json.js b/packages/module/generate-fed-package-json.js index c14056fa..42933f14 100644 --- a/packages/module/generate-fed-package-json.js +++ b/packages/module/generate-fed-package-json.js @@ -1,14 +1,14 @@ const fse = require('fs-extra'); -const { globSync } = require('glob'); const path = require('path'); const { default: getDynamicModuleMap } = require('../../scripts/parse-dynamic-modules.mjs'); +const { toPosixPath, posixGlobSync, posixRelative } = require('./utils'); -const root = process.cwd(); +const root = toPosixPath(process.cwd()); -const sourceFiles = globSync(`${root}/src/*/`) +const sourceFiles = posixGlobSync(`${root}/src/*/`) .map((name) => name.replace(/\/$/, '')); - -const indexTypings = globSync(`${root}/src/index.d.ts`); + +const indexTypings = posixGlobSync(`${root}/src/index.d.ts`); const ENV_AGNOSTIC_ROOT = `${root}/dist/dynamic` @@ -23,9 +23,9 @@ async function copyTypings(files, dest) { async function createPackage(file) { const fileName = file.split('/').pop(); - const esmSource = globSync(`${root}/dist/esm/${fileName}/**/index.js`)[0]; - const cjsSource = globSync(`${root}/dist/cjs/${fileName}/**/index.js`)[0]; - const typingsSource = globSync(`${root}/dist/esm/${fileName}/**/index.d.ts`)[0] + const esmSource = posixGlobSync(`${root}/dist/esm/${fileName}/**/index.js`)[0]; + const cjsSource = posixGlobSync(`${root}/dist/cjs/${fileName}/**/index.js`)[0]; + const typingsSource = posixGlobSync(`${root}/dist/esm/${fileName}/**/index.d.ts`)[0] /** * Prevent creating package.json for directories with no JS files (like CSS directories) */ @@ -39,14 +39,14 @@ async function createPackage(file) { // ensure the directory exists fse.ensureDirSync(destDir) - const esmRelative = path.relative(file, esmSource).replace('/dist', ''); - const cjsRelative = path.relative(file, cjsSource).replace('/dist', ''); - const tsRelative = path.relative(file, typingsSource).replace('/dist', '') + const esmRelative = posixRelative(file, esmSource).replace('/dist', ''); + const cjsRelative = posixRelative(file, cjsSource).replace('/dist', ''); + const tsRelative = posixRelative(file, typingsSource).replace('/dist', '') const content = { main: cjsRelative, module: esmRelative, }; - const typings = globSync(`${root}/src/${fileName}/*.d.ts`); + const typings = posixGlobSync(`${root}/src/${fileName}/*.d.ts`); const cmds = []; content.typings = tsRelative; cmds.push(copyTypings(typings, `${root}/dist/${fileName}`)); diff --git a/packages/module/generate-index.js b/packages/module/generate-index.js index 8ff8b416..3afa0b06 100644 --- a/packages/module/generate-index.js +++ b/packages/module/generate-index.js @@ -1,12 +1,12 @@ const fse = require('fs-extra'); -const { globSync } = require('glob'); const path = require('path'); +const { posixGlobSync } = require('./utils'); const root = process.cwd(); const ENV_AGNOSTIC_ROOT = `${root}/src` -const sourceFiles = globSync(path.resolve(__dirname, './src/*/index.ts')) +const sourceFiles = posixGlobSync(path.resolve(__dirname, './src/*/index.ts')) async function generateIndex(files) { // ensure the dynamic root exists diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/Tearsheet.md b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/Tearsheet.md new file mode 100644 index 00000000..08e1c00e --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/Tearsheet.md @@ -0,0 +1,71 @@ +--- +# Sidenav top-level section +# should be the same for all markdown files +section: extensions +subsection: component-groups +# Sidenav secondary level section +# should be the same for all markdown files +id: Tearsheet +# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) +source: react +# If you use typescript, the name of the interface to display props for +# These are found through the sourceProps function provided in patternfly-docs.source.js +propComponents: ['Tearsheet', 'TearsheetHeader', 'TearsheetBody', 'TearsheetFooter', 'TearsheetGroup'] +sourceLink: https://github.com/patternfly/react-component-groups/blob/main/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/Tearsheet.md +--- + +import { Fragment, useState } from 'react'; +import spacing from '@patternfly/react-styles/css/utilities/Spacing/spacing'; +import TearsheetGroup from '@patternfly/react-component-groups/dist/dynamic/TearsheetGroup'; +import Tearsheet from '@patternfly/react-component-groups/dist/dynamic/Tearsheet'; +import TearsheetHeader from '@patternfly/react-component-groups/dist/dynamic/TearsheetHeader'; +import TearsheetBody from '@patternfly/react-component-groups/dist/dynamic/TearsheetBody'; +import TearsheetFooter from '@patternfly/react-component-groups/dist/dynamic/TearsheetFooter'; + +**Tearsheet** are a full-screen extension of the `` component allowing more complex experiences to be provided to the user. +While the biggest Modal size (`ModalVariant.large`) may work for some cases, tearsheets allow near the entire real-estate to be leveraged. +This component extends the [modal component](/components/modal) allowing any use of its properties to be provided. + +## Examples + +### Basic + +Typical tearsheets should make use of the entire area, for this basic case some sample text is rendered. + +```ts file="./TearsheetBasic.tsx" +``` + +### Tearsheet layouts + +Tearsheets should allow various sorts of layouts to be rendered. +The `` component will handle scrolling for long content. + +```ts file="./TearsheetLayouts.tsx" +``` + +### Stacked + +One special use case with tearsheets is stacking. +When a user is using a tearsheet, if another one needs to open it can open one level "on-top" of it in a new stack. +Tearsheets offer 3 stack levels (0,1,2). +A special stack level -1 allows a tearsheet to hide behind others. + +```ts file="./TearsheetStacked.tsx" +``` + +### Tearsheet group (infinite stacking) + +Use a `TearsheetGroup` to manage an unbounded number of stacked tearsheets. +`children` rendering order determines stacking priority with later children stacking in front of earlier ones. +Only the top 3 open tearsheets are visible; earlier ones hide behind the stack and reappear as front tearsheets are closed. + +```ts file="./TearsheetGroup.tsx" +``` + +### Tearsheets vs Modals + +To illustrate the difference between a tearsheet and a modal, this example showcases a complex use case with a search bar, side panel, and a number of cards. +In a modal the content is crammed and is not as usable as if it were on a bigger area like the tearsheet. + +```ts file="./TearsheetComparison.tsx" +``` diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetBasic.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetBasic.tsx new file mode 100644 index 00000000..e25e1655 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetBasic.tsx @@ -0,0 +1,43 @@ +import { Fragment, useState } from 'react'; +import { Button } from '@patternfly/react-core'; +import Tearsheet from '@patternfly/react-component-groups/dist/dynamic/Tearsheet'; +import TearsheetHeader from '@patternfly/react-component-groups/dist/dynamic/TearsheetHeader'; +import TearsheetBody from '@patternfly/react-component-groups/dist/dynamic/TearsheetBody'; +import TearsheetFooter from '@patternfly/react-component-groups/dist/dynamic/TearsheetFooter'; + +export const TearsheetBasic: React.FunctionComponent = () => { + const [ isTearsheetOpen, setIsTearsheetOpen ] = useState(false); + + const toggleTearsheet = (_event: React.MouseEvent | KeyboardEvent | MouseEvent) => { + setIsTearsheetOpen(!isTearsheetOpen); + }; + + return ( + + + | KeyboardEvent | MouseEvent) => toggleTearsheet(e)} + > + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetComparison.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetComparison.tsx new file mode 100644 index 00000000..0c761403 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetComparison.tsx @@ -0,0 +1,259 @@ +import { Fragment, useState } from 'react'; +import { + Button, + Card, + CardBody, + CardHeader, + CardTitle, + CodeBlock, + CodeBlockCode, + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + Grid, + GridItem, + JumpLinks, + JumpLinksItem, + Label, + LabelGroup, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + PageSection, + SearchInput, + Sidebar, + SidebarContent, + SidebarPanel +} from '@patternfly/react-core'; +import Tearsheet from '@patternfly/react-component-groups/dist/dynamic/Tearsheet'; +import TearsheetHeader from '@patternfly/react-component-groups/dist/dynamic/TearsheetHeader'; +import TearsheetBody from '@patternfly/react-component-groups/dist/dynamic/TearsheetBody'; +import TearsheetFooter from '@patternfly/react-component-groups/dist/dynamic/TearsheetFooter'; + +const SIDEBAR_FIX_CLASS = 'tearsheet-comparison-sidebar'; +const sidebarFixStyles = ` + .${SIDEBAR_FIX_CLASS} .pf-v6-c-sidebar__main { height: 100%; } + .${SIDEBAR_FIX_CLASS} .pf-v6-c-sidebar__content { overflow: scroll; height: 100%; } +`; + +const LOREM = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore ' + + 'magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo ' + + 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla ' + + 'pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id ' + + 'est laborum.'; + +const sections = [ + { + id: 'overview', + title: 'Overview', + description: 'High-level summary of the resource, including its purpose, current state, and key metadata.', + labels: [ 'v1', 'stable', 'core' ], + items: [ + { name: 'ConfigMap', status: 'Active', created: '2025-06-01', owner: 'platform-team' }, + { name: 'Secret', status: 'Active', created: '2025-06-02', owner: 'security-team' }, + { name: 'ServiceAccount', status: 'Active', created: '2025-05-28', owner: 'platform-team' }, + { name: 'Namespace', status: 'Terminating', created: '2025-04-15', owner: 'admin' }, + { name: 'LimitRange', status: 'Active', created: '2025-06-03', owner: 'ops-team' }, + { name: 'ResourceQuota', status: 'Active', created: '2025-06-03', owner: 'ops-team' } + ], + code: `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: my-config\n namespace: default\n labels:\n app.kubernetes.io/name: my-app\n app.kubernetes.io/version: "1.2.0"\n app.kubernetes.io/managed-by: helm\ndata:\n APP_ENV: production\n LOG_LEVEL: info\n MAX_CONNECTIONS: "100"` + }, + { + id: 'configuration', + title: 'Configuration', + description: 'Runtime parameters, feature flags, and environment-specific settings that control application behavior.', + labels: [ 'apps/v1', 'deployment', 'rolling-update' ], + items: [ + { name: 'Deployment', status: 'Available', created: '2025-06-01', owner: 'dev-team' }, + { name: 'StatefulSet', status: 'Ready', created: '2025-06-01', owner: 'data-team' }, + { name: 'DaemonSet', status: 'Scheduled', created: '2025-05-30', owner: 'infra-team' }, + { name: 'ReplicaSet', status: 'Available', created: '2025-06-01', owner: 'dev-team' }, + { name: 'CronJob', status: 'Suspended', created: '2025-05-20', owner: 'batch-team' }, + { name: 'Job', status: 'Complete', created: '2025-06-04', owner: 'batch-team' } + ], + code: `spec:\n replicas: 3\n selector:\n matchLabels:\n app: my-app\n strategy:\n type: RollingUpdate\n rollingUpdate:\n maxSurge: 1\n maxUnavailable: 0\n template:\n spec:\n containers:\n - name: app\n image: registry.io/my-app:1.2.0\n env:\n - name: DB_HOST\n valueFrom:\n secretKeyRef:\n name: db-credentials\n key: host` + }, + { + id: 'resources', + title: 'Resources', + description: 'CPU, memory, and storage allocations for each container in the workload.', + labels: [ 'requests', 'limits', 'QoS: Burstable' ], + items: [ + { name: 'app', status: 'Running', created: '2025-06-01', owner: 'dev-team' }, + { name: 'sidecar-proxy', status: 'Running', created: '2025-06-01', owner: 'mesh-team' }, + { name: 'log-collector', status: 'Running', created: '2025-06-01', owner: 'observability' }, + { name: 'init-db', status: 'Completed', created: '2025-06-01', owner: 'dev-team' }, + { name: 'init-config', status: 'Completed', created: '2025-06-01', owner: 'platform-team' }, + { name: 'debug', status: 'Waiting', created: '2025-06-05', owner: 'sre-team' } + ], + code: `containers:\n - name: app\n resources:\n requests:\n cpu: "250m"\n memory: "512Mi"\n ephemeral-storage: "1Gi"\n limits:\n cpu: "1"\n memory: "1Gi"\n ephemeral-storage: "2Gi"\n - name: sidecar-proxy\n resources:\n requests:\n cpu: "100m"\n memory: "128Mi"\n limits:\n cpu: "200m"\n memory: "256Mi"` + }, + { + id: 'networking', + title: 'Networking', + description: 'Service exposure, ingress rules, and network policies governing traffic flow.', + labels: [ 'ClusterIP', 'Ingress', 'NetworkPolicy' ], + items: [ + { name: 'my-service', status: 'Active', created: '2025-06-01', owner: 'dev-team' }, + { name: 'my-service-headless', status: 'Active', created: '2025-06-01', owner: 'data-team' }, + { name: 'ingress-main', status: 'Synced', created: '2025-06-02', owner: 'platform-team' }, + { name: 'netpol-deny-all', status: 'Enforcing', created: '2025-05-15', owner: 'security-team' }, + { name: 'netpol-allow-web', status: 'Enforcing', created: '2025-05-15', owner: 'security-team' }, + { name: 'external-dns', status: 'Active', created: '2025-06-03', owner: 'infra-team' } + ], + code: `apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n name: my-ingress\n annotations:\n nginx.ingress.kubernetes.io/rewrite-target: /\n cert-manager.io/cluster-issuer: letsencrypt\nspec:\n tls:\n - hosts:\n - app.example.com\n secretName: app-tls\n rules:\n - host: app.example.com\n http:\n paths:\n - path: /api\n pathType: Prefix\n backend:\n service:\n name: my-service\n port:\n number: 8080` + }, + { + id: 'storage', + title: 'Storage', + description: 'Persistent volume claims, storage classes, and mount configurations.', + labels: [ 'gp3', 'ReadWriteOnce', 'Retain' ], + items: [ + { name: 'data-pvc', status: 'Bound', created: '2025-06-01', owner: 'data-team' }, + { name: 'logs-pvc', status: 'Bound', created: '2025-06-01', owner: 'observability' }, + { name: 'backup-pvc', status: 'Bound', created: '2025-05-20', owner: 'ops-team' }, + { name: 'tmp-pvc', status: 'Pending', created: '2025-06-05', owner: 'dev-team' }, + { name: 'cache-emptydir', status: 'Mounted', created: '2025-06-01', owner: 'dev-team' }, + { name: 'config-projected', status: 'Mounted', created: '2025-06-01', owner: 'platform-team' } + ], + code: `apiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n name: data-pvc\nspec:\n accessModes:\n - ReadWriteOnce\n storageClassName: gp3-encrypted\n resources:\n requests:\n storage: 50Gi\n---\nvolumeMounts:\n - name: data\n mountPath: /var/lib/data\n - name: logs\n mountPath: /var/log/app\n - name: cache\n mountPath: /tmp/cache\nvolumes:\n - name: data\n persistentVolumeClaim:\n claimName: data-pvc\n - name: cache\n emptyDir:\n sizeLimit: 500Mi` + }, + { + id: 'monitoring', + title: 'Monitoring', + description: 'Health checks, readiness probes, and metrics endpoints used for observability.', + labels: [ 'prometheus', 'liveness', 'readiness' ], + items: [ + { name: 'liveness-http', status: 'Passing', created: '2025-06-01', owner: 'dev-team' }, + { name: 'readiness-http', status: 'Passing', created: '2025-06-01', owner: 'dev-team' }, + { name: 'startup-tcp', status: 'Passing', created: '2025-06-01', owner: 'dev-team' }, + { name: 'metrics-endpoint', status: 'Scraping', created: '2025-06-02', owner: 'observability' }, + { name: 'alert-high-cpu', status: 'Firing', created: '2025-05-10', owner: 'sre-team' }, + { name: 'alert-error-rate', status: 'Pending', created: '2025-05-10', owner: 'sre-team' } + ], + code: `livenessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 15\n periodSeconds: 10\n failureThreshold: 3\nreadinessProbe:\n httpGet:\n path: /readyz\n port: 8080\n initialDelaySeconds: 5\n periodSeconds: 5\nstartupProbe:\n tcpSocket:\n port: 8080\n failureThreshold: 30\n periodSeconds: 2\n---\napiVersion: monitoring.coreos.com/v1\nkind: ServiceMonitor\nmetadata:\n name: my-app-monitor\nspec:\n selector:\n matchLabels:\n app: my-app\n endpoints:\n - port: metrics\n interval: 15s\n path: /metrics` + } +]; + +const renderBodyContent = (sidebarClassName = '') => ( + + + + +
+ + + + {sections.map((s) => ( + e.preventDefault()}> + {s.title} + + ))} + + + + + {sections.map((section) => ( + + + + {section.title} + + {section.labels.map((l) => ( + + ))} + + + {section.description} + + {section.items.map((item, i) => ( + + + + {item.name} + + + + + Status + {item.status} + + + Created + {item.created} + + + Owner + {item.owner} + + + {LOREM.slice(0, 120)} + + {section.code} + + + + + ))} + + ))} + + + +
+
+); + +export const TearsheetComparison: React.FunctionComponent = () => { + const [ isTearsheetOpen, setIsTearsheetOpen ] = useState(false); + const [ isModalOpen, setIsModalOpen ] = useState(false); + + const closeTearsheet = () => setIsTearsheetOpen(false); + const closeModal = () => setIsModalOpen(false); + + return ( + + + + + + + + + + {renderBodyContent(SIDEBAR_FIX_CLASS)} + + + + + + + + + {renderBodyContent()} + + + + + + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetGroup.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetGroup.tsx new file mode 100644 index 00000000..0f10d2f8 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetGroup.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { Button } from '@patternfly/react-core'; +import TearsheetGroup from '@patternfly/react-component-groups/dist/dynamic/TearsheetGroup'; +import Tearsheet from '@patternfly/react-component-groups/dist/dynamic/Tearsheet'; +import TearsheetHeader from '@patternfly/react-component-groups/dist/dynamic/TearsheetHeader'; +import TearsheetBody from '@patternfly/react-component-groups/dist/dynamic/TearsheetBody'; +import TearsheetFooter from '@patternfly/react-component-groups/dist/dynamic/TearsheetFooter'; + +const TOTAL_TEARSHEETS = 10; + +export const TearsheetGroupExample: React.FunctionComponent = () => { + const [ openState, setOpenState ] = useState(Array(TOTAL_TEARSHEETS).fill(false)); + + const open = (index: number) => { + setOpenState((prev) => { + const next = [ ...prev ]; + next[index] = true; + return next; + }); + }; + + const close = (index: number) => { + setOpenState((prev) => { + const next = [ ...prev ]; + next[index] = false; + return next; + }); + }; + + return ( +
+
+ +
+ + + {Array.from({ length: TOTAL_TEARSHEETS }, (_, i) => ( + close(i)} aria-label={`Tearsheet ${i + 1}`}> + + +

+ This is tearsheet #{i + 1} of {TOTAL_TEARSHEETS}. +

+

+ The TearsheetGroup manages stacking automatically. Only the top 3 open tearsheets are visible in the + stack — earlier ones hide behind and reappear as you close the ones in front. +

+
+ + {i < TOTAL_TEARSHEETS - 1 && ( + + )} + + +
+ ))} +
+
+ ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetLayouts.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetLayouts.tsx new file mode 100644 index 00000000..33095bf7 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetLayouts.tsx @@ -0,0 +1,117 @@ +import { Fragment, useState } from 'react'; +import { + Button, + Card, + CardBody, + CardHeader, + CardTitle, + Content, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Divider, + Title +} from '@patternfly/react-core'; +import Tearsheet from '@patternfly/react-component-groups/dist/dynamic/Tearsheet'; +import TearsheetHeader from '@patternfly/react-component-groups/dist/dynamic/TearsheetHeader'; +import TearsheetBody from '@patternfly/react-component-groups/dist/dynamic/TearsheetBody'; +import TearsheetFooter from '@patternfly/react-component-groups/dist/dynamic/TearsheetFooter'; +import { Flex, FlexItem, Grid, GridItem } from '@patternfly/react-core'; + +type BodyLayout = 'long-text' | 'grid' | 'long'; + +const LOREM = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore ' + + 'magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo ' + + 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla ' + + 'pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id ' + + 'est laborum.'; + +const bodyLayouts: Record React.ReactNode }> = { + 'long-text': { + label: 'Long text', + render: () => ( + <> + {Array.from({ length: 30 }, (_, i) => ( + + {LOREM} + + ))} + + ) + }, + grid: { + label: 'Grid layout', + render: () => ( + + {[ 'Overview', 'Configuration', 'Resources', 'Networking', 'Storage', 'Monitoring' ] + .map((title) => new Array(10).fill(title)) + .flat() + .sort(() => Math.random() - 0.5) + .map((title) => ( + + + + {title} + + {LOREM.slice(0, 120)}... + + + ))} + + ) + }, + long: { + label: 'Flex layout', + render: () => ( + + {[ 'General', 'Details', 'Configuration', 'Permissions', 'Audit log' ].map((section) => ( + + {section} + + + {[ 'Name', 'Status', 'Created', 'Modified' ].map((term) => ( + + {term} + {LOREM.slice(0, 80)} + + ))} + + + ))} + + ) + } +}; + +export const TearsheetLayouts: React.FunctionComponent = () => { + const [ activeLayout, setActiveLayout ] = useState(null); + + const open = (layout: BodyLayout) => () => setActiveLayout(layout); + const close = (_event: React.MouseEvent | KeyboardEvent | MouseEvent) => setActiveLayout(null); + + return ( + + + {(Object.keys(bodyLayouts) as BodyLayout[]).map((key) => ( + + ))} + + + + {activeLayout && bodyLayouts[activeLayout].render()} + + + + + + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetStacked.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetStacked.tsx new file mode 100644 index 00000000..c2f35398 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/Tearsheet/TearsheetStacked.tsx @@ -0,0 +1,101 @@ +import { Fragment, useState } from 'react'; +import { Button } from '@patternfly/react-core'; +import Tearsheet from '@patternfly/react-component-groups/dist/dynamic/Tearsheet'; +import TearsheetHeader from '@patternfly/react-component-groups/dist/dynamic/TearsheetHeader'; +import TearsheetBody from '@patternfly/react-component-groups/dist/dynamic/TearsheetBody'; +import TearsheetFooter from '@patternfly/react-component-groups/dist/dynamic/TearsheetFooter'; + +export const TearsheetStacked: React.FunctionComponent = () => { + const [ isTearsheetOpen, setIsTearsheetOpen ] = useState(false); + const [ isStack1TearsheetOpen, setIsStack1TearsheetOpen ] = useState(false); + const [ isStack2TearsheetOpen, setIsStack2TearsheetOpen ] = useState(false); + + const toggleTearsheet = (_event: React.MouseEvent | KeyboardEvent | MouseEvent) => { + setIsTearsheetOpen(!isTearsheetOpen); + }; + const toggleStack1Tearsheet = (_event: React.MouseEvent | KeyboardEvent | MouseEvent) => { + setIsStack1TearsheetOpen(!isStack1TearsheetOpen); + }; + const toggleStack2Tearsheet = (_event: React.MouseEvent | KeyboardEvent | MouseEvent) => { + setIsStack2TearsheetOpen(!isStack2TearsheetOpen); + }; + + return ( + + + | KeyboardEvent | MouseEvent) => toggleTearsheet(e)} + > + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + + | KeyboardEvent | MouseEvent) => toggleStack1Tearsheet(e)} + > + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + + | KeyboardEvent | MouseEvent) => toggleStack2Tearsheet(e)} + > + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + + ); +}; diff --git a/packages/module/patternfly-docs/patternfly-docs.source.js b/packages/module/patternfly-docs/patternfly-docs.source.js index cff7aac9..f0127391 100644 --- a/packages/module/patternfly-docs/patternfly-docs.source.js +++ b/packages/module/patternfly-docs/patternfly-docs.source.js @@ -1,14 +1,15 @@ const path = require('path'); +const { toPosixPath } = require('../utils'); module.exports = (sourceMD, sourceProps) => { // Parse source content for props so that we can display them const propsIgnore = [ '**/*.test.tsx', '**/examples/*.tsx' ]; const extensionPath = path.join(__dirname, '../src'); - sourceProps(path.join(extensionPath, '/**/*.tsx'), propsIgnore); + sourceProps(toPosixPath(path.join(extensionPath, '/**/*.tsx')), propsIgnore); // Parse md files const contentBase = path.join(__dirname, './content'); - sourceMD(path.join(contentBase, 'extensions/**/*.md'), 'extensions'); + sourceMD(toPosixPath(path.join(contentBase, 'extensions/**/*.md')), 'extensions'); /** If you want to parse content from node_modules instead of providing a relative/absolute path, diff --git a/packages/module/src/Tearsheet/Tearsheet.tsx b/packages/module/src/Tearsheet/Tearsheet.tsx new file mode 100644 index 00000000..2a137e01 --- /dev/null +++ b/packages/module/src/Tearsheet/Tearsheet.tsx @@ -0,0 +1,128 @@ +import type { ReactNode, HTMLProps, FunctionComponent, MouseEvent } from 'react'; +import { css } from '@patternfly/react-styles'; +import { createUseStyles } from 'react-jss'; +import { Modal, ModalVariant } from '@patternfly/react-core'; + +const useStyles = createUseStyles({ + tearsheet: { + width: 'calc(100% - 4rem) !important', + maxWidth: 'calc(100% - 4rem) !important', + height: 'calc(100% - 4rem) !important', + maxHeight: 'calc(100% - 4rem) !important', + insetBlockStart: '2rem !important', + // top: '2rem !important', + borderBottomLeftRadius: '0', + borderBottomRightRadius: '0', + '&.pf-v6-c-modal-animated': { + '--pf-v6-c-modal-animated--Transition': + 'width 300ms ease, max-width 300ms ease, height 300ms ease, max-height 300ms ease, top 300ms ease, opacity 240ms cubic-bezier(0.4, 0.14, 1, 1), transform 240ms cubic-bezier(0.4, 0.14, 1, 1), visibility 0ms linear 240ms', + }, + '&.pf-v6-c-modal-animated-open': { + '--pf-v6-c-modal-animated--Transition': + 'width 300ms ease, max-width 300ms ease, height 300ms ease, max-height 300ms ease, top 300ms ease, transform 240ms cubic-bezier(0, 0, 0.2, 1), visibility 0ms linear 0ms', + }, + '&.pf-m-stack-level-1': { + width: 'calc(100% - 2rem) !important', + maxWidth: 'calc(100% - 2rem) !important', + height: 'calc(100% - 6rem) !important', + maxHeight: 'calc(100% - 6rem) !important', + insetBlockStart: '3rem !important', + // top: '3rem !important', + }, + '&.pf-m-stack-level-2': { + width: 'calc(100% - 0rem) !important', + maxWidth: 'calc(100% - 0rem) !important', + height: 'calc(100% - 8rem) !important', + maxHeight: 'calc(100% - 8rem) !important', + insetBlockStart: '4rem !important', + // top: '4rem !important', + }, + '&.pf-m-stack-hidden': { + width: 'calc(100% - 4rem) !important', + maxWidth: 'calc(100% - 4rem) !important', + height: 'calc(100% - 4rem) !important', + maxHeight: 'calc(100% - 4rem) !important', + insetBlockStart: '3rem !important', + // top: '3rem !important', + opacity: '0 !important', + pointerEvents: 'none !important', + }, + }, + tearsheetInner: { + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + flex: '1 1 auto', + minHeight: 0, + marginInlineEnd: 0, + }, +}); + +export interface TearsheetProps extends HTMLProps { + /** Content rendered inside the tearsheet. Should be TearsheetHeader, TearsheetBody, and/or TearsheetFooter. */ + children: ReactNode; + /** Additional classes added to the tearsheet. */ + className?: string; + /** Flag to show the tearsheet. */ + isOpen?: boolean; + /** Visual stack level of the tearsheet. Managed automatically by TearsheetGroup. + * When used standalone: 0 (back), 1 (middle), 2 (front). + * TearsheetGroup may also assign -1 (hidden behind the stack). */ + stackLevel?: number; + /** A callback for when the close button is clicked. This prop needs to be passed to render the close button. */ + onClose?: (event: KeyboardEvent | MouseEvent) => void; + /** A callback for when the tearsheet is closed via the escape key. */ + onEscapePress?: (event: KeyboardEvent) => void; + /** The parent container to append the tearsheet to. Defaults to document.body. */ + appendTo?: HTMLElement | (() => HTMLElement); + /** Accessible label for the tearsheet. */ + 'aria-label'?: string; + /** ID of the element that labels the tearsheet. */ + 'aria-labelledby'?: string; + /** ID of the element that describes the tearsheet. */ + 'aria-describedby'?: string; + /** Flag to disable focus trap. */ + disableFocusTrap?: boolean; +} + +const Tearsheet: FunctionComponent = ({ + children, + className, + isOpen = false, + stackLevel: stackLevelProp, + onClose, + onEscapePress, + appendTo, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + disableFocusTrap, + ...props +}: TearsheetProps) => { + const classes = useStyles(); + const stackLevel = stackLevelProp ?? 0; + const stackLevelClassname = stackLevel < 0 ? 'pf-m-stack-hidden' : `pf-m-stack-level-${stackLevel}`; + + return ( + +
+ {children} +
+
+ ); +}; +Tearsheet.displayName = 'Tearsheet'; + +export default Tearsheet; diff --git a/packages/module/src/Tearsheet/index.ts b/packages/module/src/Tearsheet/index.ts new file mode 100644 index 00000000..4342d637 --- /dev/null +++ b/packages/module/src/Tearsheet/index.ts @@ -0,0 +1,2 @@ +export { default } from './Tearsheet'; +export * from './Tearsheet'; diff --git a/packages/module/src/TearsheetBody/TearsheetBody.tsx b/packages/module/src/TearsheetBody/TearsheetBody.tsx new file mode 100644 index 00000000..3fe30e29 --- /dev/null +++ b/packages/module/src/TearsheetBody/TearsheetBody.tsx @@ -0,0 +1,24 @@ +import type { FunctionComponent } from 'react'; +import { css } from '@patternfly/react-styles'; +import { createUseStyles } from 'react-jss'; +import { ModalBody, type ModalBodyProps } from '@patternfly/react-core'; + +const useStyles = createUseStyles({ + tearsheetBody: { + flex: 1, + minHeight: 0, + overflow: 'auto', + }, +}); + +export interface TearsheetBodyProps extends ModalBodyProps { + className?: string; +} + +const TearsheetBody: FunctionComponent = ({ className, ...props }: TearsheetBodyProps) => { + const classes = useStyles(); + return ; +}; +TearsheetBody.displayName = 'TearsheetBody'; + +export default TearsheetBody; \ No newline at end of file diff --git a/packages/module/src/TearsheetBody/index.ts b/packages/module/src/TearsheetBody/index.ts new file mode 100644 index 00000000..7229a1b8 --- /dev/null +++ b/packages/module/src/TearsheetBody/index.ts @@ -0,0 +1,2 @@ +export { default } from './TearsheetBody'; +export * from './TearsheetBody'; diff --git a/packages/module/src/TearsheetFooter/TearsheetFooter.tsx b/packages/module/src/TearsheetFooter/TearsheetFooter.tsx new file mode 100644 index 00000000..75db1d52 --- /dev/null +++ b/packages/module/src/TearsheetFooter/TearsheetFooter.tsx @@ -0,0 +1,25 @@ +import type { FunctionComponent } from 'react'; +import { css } from '@patternfly/react-styles'; +import { createUseStyles } from 'react-jss'; +import { ModalFooter, type ModalFooterProps } from '@patternfly/react-core'; + +const useStyles = createUseStyles({ + tearsheetFooter: { + flexShrink: 0, + }, +}); + +export interface TearsheetFooterProps extends ModalFooterProps { + className?: string; +} + +export const TearsheetFooter: FunctionComponent = ({ + className, + ...props +}: TearsheetFooterProps) => { + const classes = useStyles(); + return ; +}; +TearsheetFooter.displayName = 'TearsheetFooter'; + +export default TearsheetFooter; diff --git a/packages/module/src/TearsheetFooter/index.ts b/packages/module/src/TearsheetFooter/index.ts new file mode 100644 index 00000000..97946ba7 --- /dev/null +++ b/packages/module/src/TearsheetFooter/index.ts @@ -0,0 +1,2 @@ +export { default } from './TearsheetFooter'; +export * from './TearsheetFooter'; diff --git a/packages/module/src/TearsheetGroup/TearsheetGroup.tsx b/packages/module/src/TearsheetGroup/TearsheetGroup.tsx new file mode 100644 index 00000000..e25d1246 --- /dev/null +++ b/packages/module/src/TearsheetGroup/TearsheetGroup.tsx @@ -0,0 +1,88 @@ +import { Children, cloneElement, isValidElement, useRef, type FunctionComponent, type ReactElement } from 'react'; +import { css } from '@patternfly/react-styles'; +import { createUseStyles } from 'react-jss'; +import Tearsheet, { type TearsheetProps } from '../Tearsheet'; + +const useStyles = createUseStyles({ + tearsheetGroup: { + }, +}); + +/** The maximum number of visually distinct stack levels (0, 1, 2). */ +const MAX_VISIBLE_LEVELS = 3; + +export interface TearsheetGroupProps { + /** Set of Tearsheets to render inside the group. Render order determines stacking + * priority — later children stack in front of earlier ones. Only Tearsheets with + * isOpen={true} participate in the visual stack. */ + children?: React.ReactNode; + /** Additional classes added to the Tearsheet group. */ + className?: string; + /** Unique id for the Tearsheet group. */ + id: string; +} + +const TearsheetGroup: FunctionComponent = ({ + children, + className, + id, + ...props +}: TearsheetGroupProps) => { + const classes = useStyles(); + // Track each child's last assigned stack level so closing tearsheets keep + // their position during the modal exit animation instead of snapping to L0. + const prevLevelsRef = useRef>(new Map()); + + const openIndices: number[] = []; + Children.forEach(children, (child, index) => { + if (isValidElement(child) && child.type === Tearsheet && (child.props as TearsheetProps).isOpen) { + openIndices.push(index); + } + }); + + const totalOpen = openIndices.length; + const hiddenThreshold = Math.max(0, totalOpen - MAX_VISIBLE_LEVELS); + + const enhancedChildren = Children.map(children, (child, index) => { + if (!isValidElement(child) || child.type !== Tearsheet) { + return child; + } + + const openPosition = openIndices.indexOf(index); + + if (openPosition === -1) { + // Use the last known level if this tearsheet was previously open, so the + // modal close animation plays without a conflicting size/position shift. + // For never-opened tearsheets, prime them at the level they'd occupy if + // they opened next (the frontmost slot). The tearsheet is invisible at + // this point, so the pre-sizing has no visual effect — but it prevents a + // width/height/top transition from firing alongside the modal enter + // animation when the tearsheet does open. + const level = prevLevelsRef.current.get(index) ?? Math.min(totalOpen, MAX_VISIBLE_LEVELS - 1); + return cloneElement(child as ReactElement, { + stackLevel: level + }); + } + + // Levels fill from 0 upward: 1 open → L0, 2 open → L0+L1, 3 open → L0+L1+L2. + // Once all 3 visible slots are used, earlier tearsheets hide behind the stack. + const stackLevel = openPosition < hiddenThreshold ? -1 : openPosition - hiddenThreshold; + const isFrontmost = openPosition === totalOpen - 1; + + prevLevelsRef.current.set(index, stackLevel); + + return cloneElement(child as ReactElement, { + stackLevel, + disableFocusTrap: !isFrontmost + }); + }); + + return ( +
+ {enhancedChildren} +
+ ); +}; +TearsheetGroup.displayName = 'TearsheetGroup'; + +export default TearsheetGroup; diff --git a/packages/module/src/TearsheetGroup/index.ts b/packages/module/src/TearsheetGroup/index.ts new file mode 100644 index 00000000..a6827ad9 --- /dev/null +++ b/packages/module/src/TearsheetGroup/index.ts @@ -0,0 +1,2 @@ +export { default } from './TearsheetGroup'; +export * from './TearsheetGroup'; diff --git a/packages/module/src/TearsheetHeader/TearsheetHeader.tsx b/packages/module/src/TearsheetHeader/TearsheetHeader.tsx new file mode 100644 index 00000000..1c15fdc4 --- /dev/null +++ b/packages/module/src/TearsheetHeader/TearsheetHeader.tsx @@ -0,0 +1,25 @@ +import type { FunctionComponent } from 'react'; +import { css } from '@patternfly/react-styles'; +import { createUseStyles } from 'react-jss'; +import { ModalHeader, type ModalHeaderProps } from '@patternfly/react-core'; + +const useStyles = createUseStyles({ + tearsheetHeader: { + flexShrink: 0, + }, +}); + +export interface TearsheetHeaderProps extends ModalHeaderProps { + className?: string; +} + +const TearsheetHeader: FunctionComponent = ({ + className, + ...props +}: TearsheetHeaderProps) => { + const classes = useStyles(); + return ; +}; +TearsheetHeader.displayName = 'TearsheetHeader'; + +export default TearsheetHeader; diff --git a/packages/module/src/TearsheetHeader/index.ts b/packages/module/src/TearsheetHeader/index.ts new file mode 100644 index 00000000..eb954f6d --- /dev/null +++ b/packages/module/src/TearsheetHeader/index.ts @@ -0,0 +1,2 @@ +export { default } from './TearsheetHeader'; +export * from './TearsheetHeader'; diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index 4f23c2d4..c1ff0b47 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -9,6 +9,21 @@ export * from './UnavailableContent'; export { default as UnauthorizedAccess } from './UnauthorizedAccess'; export * from './UnauthorizedAccess'; +export { default as TearsheetHeader } from './TearsheetHeader'; +export * from './TearsheetHeader'; + +export { default as TearsheetGroup } from './TearsheetGroup'; +export * from './TearsheetGroup'; + +export { default as TearsheetFooter } from './TearsheetFooter'; +export * from './TearsheetFooter'; + +export { default as TearsheetBody } from './TearsheetBody'; +export * from './TearsheetBody'; + +export { default as Tearsheet } from './Tearsheet'; +export * from './Tearsheet'; + export { default as TagCount } from './TagCount'; export * from './TagCount'; diff --git a/packages/module/utils.js b/packages/module/utils.js new file mode 100644 index 00000000..e88e4ec2 --- /dev/null +++ b/packages/module/utils.js @@ -0,0 +1,36 @@ +/** + * Cross-platform path utilities for build scripts. + * + * On Windows, Node's path.resolve and path.relative produce backslash-separated + * paths. glob v10+ treats backslashes as escape characters (not path separators), + * so a pattern like `C:\src\*\index.ts` silently matches nothing because \* is + * interpreted as a literal asterisk. glob also returns backslash paths on Windows, + * breaking downstream code that splits on '/' to extract path segments. + * + * These utilities normalize paths to POSIX forward slashes for glob input/output + * and for any path strings written into generated package.json files. + */ + +const { globSync } = require('glob'); +const path = require('path'); + +/** Normalize a file path to use POSIX forward slashes. No-op on Linux/macOS. */ +const toPosixPath = (filePath) => filePath.replace(/\\/g, '/'); + +/** + * globSync wrapper that normalizes the pattern and results to POSIX paths. + * Use in place of globSync wherever the pattern includes Node-resolved paths + * (process.cwd(), path.resolve, __dirname, etc.) that may contain backslashes. + */ +const posixGlobSync = (pattern) => + globSync(toPosixPath(pattern)).map(toPosixPath); + +/** + * path.relative wrapper that returns a POSIX-style relative path. + * Use when the result will be written into a generated file (e.g. package.json + * "main"/"module" fields) where forward slashes are expected by consumers. + */ +const posixRelative = (from, to) => + toPosixPath(path.relative(from, to)); + +module.exports = { toPosixPath, posixGlobSync, posixRelative }; diff --git a/scripts/parse-dynamic-modules.mjs b/scripts/parse-dynamic-modules.mjs index 6a35cec7..97c88ba1 100644 --- a/scripts/parse-dynamic-modules.mjs +++ b/scripts/parse-dynamic-modules.mjs @@ -76,8 +76,10 @@ const getDynamicModuleMap = ( return {}; } + const normalizedBasePath = basePath.replace(/\\/g, '/'); + /** @type {Record} */ - const dynamicModulePathToPkgDir = glob.sync(`${basePath}/dist/dynamic/**/package.json`).reduce((acc, pkgFile) => { + const dynamicModulePathToPkgDir = glob.sync(`${normalizedBasePath}/dist/dynamic/**/package.json`).reduce((acc, pkgFile) => { const pkg = require(pkgFile); const pkgModule = pkg[resolutionField]; @@ -86,7 +88,7 @@ const getDynamicModuleMap = ( } const pkgResolvedPath = path.resolve(path.dirname(pkgFile), pkgModule); - const pkgRelativePath = path.dirname(path.relative(basePath, pkgFile)); + const pkgRelativePath = path.dirname(path.relative(basePath, pkgFile)).replace(/\\/g, '/'); acc[pkgResolvedPath] = pkgRelativePath;