diff --git a/packages/ui-breadcrumb/src/Breadcrumb/__tests__/Breadcrumb.test.tsx b/packages/ui-breadcrumb/src/Breadcrumb/__tests__/Breadcrumb.test.tsx
index dc1bae6526..3082b49ddf 100644
--- a/packages/ui-breadcrumb/src/Breadcrumb/__tests__/Breadcrumb.test.tsx
+++ b/packages/ui-breadcrumb/src/Breadcrumb/__tests__/Breadcrumb.test.tsx
@@ -65,6 +65,32 @@ describe('', () => {
expect(axeCheck).toBe(true)
})
+ it('should stay on a single line when the crumbs do not fit', async () => {
+ const crumbs = (
+
+ English literature 204
+ Second term modules
+ Current lesson
+
+ )
+ const { container } = await render(
+
+
+ {crumbs}
+
+
+ {crumbs}
+
+
+ )
+ const [wideList, narrowList] = [...container.querySelectorAll('ol')].map(
+ (list) => list.getBoundingClientRect()
+ )
+
+ expect(narrowList.width).toBeLessThan(wideList.width)
+ expect(narrowList.height).toBe(wideList.height)
+ })
+
it('should render the label as an aria-label attribute', async () => {
await render(
diff --git a/packages/ui-breadcrumb/src/Breadcrumb/__tests__/BreadcrumbLink.test.tsx b/packages/ui-breadcrumb/src/Breadcrumb/__tests__/BreadcrumbLink.test.tsx
index 1a70cb8bd9..c42ad4cfb1 100644
--- a/packages/ui-breadcrumb/src/Breadcrumb/__tests__/BreadcrumbLink.test.tsx
+++ b/packages/ui-breadcrumb/src/Breadcrumb/__tests__/BreadcrumbLink.test.tsx
@@ -124,6 +124,70 @@ describe('', () => {
expect(span).toMatchTextContent(TEST_TEXT_01)
})
+ describe('truncation', () => {
+ const LONG_TEXT = 'A very long breadcrumb text that does not fit'
+
+ it('should truncate long text with CSS and keep the full text in the DOM', async () => {
+ const { container } = await render(
+
+ {LONG_TEXT}
+
+ )
+ const link = page.getByRole('link').element()
+ const text = container.querySelector('a > span')!
+
+ expect(link).toMatchTextContent(LONG_TEXT)
+ expect(text).toHaveStyle('white-space: nowrap')
+ expect(text).toHaveStyle('text-overflow: ellipsis')
+ expect(text.scrollWidth).toBeGreaterThan(text.clientWidth)
+ })
+
+ it('should truncate long text next to an icon', async () => {
+ const { container } = await render(
+
+ }
+ >
+ {LONG_TEXT}
+
+
+ )
+ const link = page.getByRole('link').element()
+ const text = container.querySelector('a > span:last-child')!
+ const icon = page.getByTestId('icon').element()
+
+ expect(text).toMatchTextContent(LONG_TEXT)
+ expect(text.scrollWidth).toBeGreaterThan(text.clientWidth)
+ expect(link.getBoundingClientRect().height).toBeLessThan(
+ icon.getBoundingClientRect().height * 2
+ )
+ })
+
+ it('should show the full text in a tooltip only when truncated', async () => {
+ await render(
+
+
+ {LONG_TEXT}
+
+ {TEST_TEXT_01}
+
+ )
+ const [truncatedLink, shortLink] = page.getByRole('link').all()
+
+ await userEvent.hover(truncatedLink)
+ await expect
+ .element(page.getByRole('tooltip'))
+ .toMatchTextContent(LONG_TEXT)
+
+ await userEvent.unhover(truncatedLink)
+ await expect.element(page.getByRole('tooltip')).not.toBeInTheDocument()
+
+ await userEvent.hover(shortLink)
+ await expect.element(page.getByRole('tooltip')).not.toBeInTheDocument()
+ })
+ })
+
it('should meet a11y standards as a link', async () => {
const { container } = await render(
{TEST_TEXT_01}
diff --git a/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/index.tsx b/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/index.tsx
index b7aceeea23..0b6046485a 100644
--- a/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/index.tsx
+++ b/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/index.tsx
@@ -24,10 +24,12 @@
import { Component } from 'react'
-import { TruncateText } from '@instructure/ui-truncate-text/latest'
import { Link } from '@instructure/ui-link/latest'
import { omitProps } from '@instructure/ui-react-utils'
import { Tooltip } from '@instructure/ui-tooltip/latest'
+import { withStyleNew } from '@instructure/emotion'
+
+import generateStyle from './styles.js'
import { allowedProps } from './props.js'
import type { BreadcrumbLinkProps, BreadcrumbLinkState } from './props'
@@ -39,6 +41,7 @@ id: Breadcrumb.Link
---
**/
+@withStyleNew(generateStyle)
class BreadcrumbLink extends Component<
BreadcrumbLinkProps,
BreadcrumbLinkState
@@ -50,10 +53,17 @@ class BreadcrumbLink extends Component<
static defaultProps = {}
ref: Element | null = null
+ private _textRef: HTMLSpanElement | null = null
+ private _resizeObserver?: ResizeObserver
handleRef = (el: Element | null) => {
this.ref = el
}
+
+ handleTextRef = (el: HTMLSpanElement | null) => {
+ this._textRef = el
+ }
+
constructor(props: BreadcrumbLinkProps) {
super(props)
@@ -61,7 +71,32 @@ class BreadcrumbLink extends Component<
isTruncated: false
}
}
- handleTruncation(isTruncated: boolean) {
+
+ componentDidMount() {
+ this.props.makeStyles?.()
+
+ if (this._textRef && typeof ResizeObserver !== 'undefined') {
+ this._resizeObserver = new ResizeObserver(this.checkTruncation)
+ this._resizeObserver.observe(this._textRef)
+ }
+ this.checkTruncation()
+ }
+
+ componentDidUpdate() {
+ this.props.makeStyles?.()
+ this.checkTruncation()
+ }
+
+ componentWillUnmount() {
+ this._resizeObserver?.disconnect()
+ }
+
+ checkTruncation = () => {
+ if (!this._textRef) {
+ return
+ }
+ const isTruncated = this._textRef.scrollWidth > this._textRef.clientWidth
+
if (isTruncated !== this.state.isTruncated) {
this.setState({ isTruncated })
}
@@ -76,7 +111,8 @@ class BreadcrumbLink extends Component<
onClick,
onMouseEnter,
isCurrentPage,
- size
+ size,
+ styles
} = this.props
const { isTruncated } = this.state
const props = omitProps(this.props, BreadcrumbLink.allowedProps)
@@ -87,7 +123,7 @@ class BreadcrumbLink extends Component<
renderTip={children}
preventTooltip={!isTruncated}
// this wraps the achor/button tag in a span and puts the aria-describedby on that instead of the anchor/button tag
- // to avoid SRs reading the text twice when there is already an aria-label
+ // to avoid SRs reading the text twice
{...(isInteractive && { as: 'span' })}
>
- this.handleTruncation(isTruncated)}
- >
- {children}
-
+
+ {children}
+
)
diff --git a/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/props.ts b/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/props.ts
index ff46883d19..9082f19ba6 100644
--- a/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/props.ts
+++ b/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/props.ts
@@ -30,6 +30,7 @@ import type {
} from '@instructure/shared-types'
import type { ViewOwnProps } from '@instructure/ui-view/latest'
import type { LinkProps } from '@instructure/ui-link/latest'
+import type { WithStyleProps, ComponentStyle } from '@instructure/emotion'
type BreadcrumbLinkOwnProps = {
/**
@@ -81,7 +82,10 @@ type BreadcrumbLinkProps = PickPropsWithExceptions<
| 'elementRef'
> &
BreadcrumbLinkOwnProps &
+ WithStyleProps &
OtherHTMLAttributes
+
+type BreadcrumbLinkStyle = ComponentStyle<'text'>
const allowedProps: AllowedPropKeys = [
'children',
'href',
@@ -97,5 +101,5 @@ type BreadcrumbLinkState = {
isTruncated: boolean
}
-export type { BreadcrumbLinkProps, BreadcrumbLinkState }
+export type { BreadcrumbLinkProps, BreadcrumbLinkState, BreadcrumbLinkStyle }
export { allowedProps }
diff --git a/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/styles.ts b/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/styles.ts
new file mode 100644
index 0000000000..24a3054863
--- /dev/null
+++ b/packages/ui-breadcrumb/src/Breadcrumb/v2/BreadcrumbLink/styles.ts
@@ -0,0 +1,47 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2015 - present Instructure, Inc.
+ *
+ * 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.
+ */
+
+import type { BreadcrumbLinkStyle } from './props'
+
+/**
+ * ---
+ * private: true
+ * ---
+ * Generates the style object from the theme and provided additional information
+ * @param {Object} _componentTheme The theme variable object.
+ * @return {Object} The final style object, which will be used in the component
+ */
+const generateStyle = (_componentTheme: never): BreadcrumbLinkStyle => {
+ return {
+ text: {
+ label: 'breadcrumbLink__text',
+ display: 'block',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap'
+ }
+ }
+}
+
+export default generateStyle
diff --git a/packages/ui-breadcrumb/src/Breadcrumb/v2/styles.ts b/packages/ui-breadcrumb/src/Breadcrumb/v2/styles.ts
index 16ad4e6bec..f5b02aa8bb 100644
--- a/packages/ui-breadcrumb/src/Breadcrumb/v2/styles.ts
+++ b/packages/ui-breadcrumb/src/Breadcrumb/v2/styles.ts
@@ -60,18 +60,7 @@ const generateStyle = (
crumb: {
label: 'breadcrumb__crumb',
boxSizing: 'border-box',
- display: 'flex',
- alignItems: 'center',
-
- // prevent text clipping
- '[data-cid~="TruncateText"]': {
- overflow: 'visible',
-
- // prevent extra spacing after the '...'
- '& [class*="truncateText__spacer"]': {
- display: 'inline'
- }
- }
+ display: 'block'
},
separator: {
label: 'breadcrumb__separator',
diff --git a/packages/ui-link/src/Link/v2/index.tsx b/packages/ui-link/src/Link/v2/index.tsx
index fa34447bbf..d3c4c7a1e2 100644
--- a/packages/ui-link/src/Link/v2/index.tsx
+++ b/packages/ui-link/src/Link/v2/index.tsx
@@ -25,7 +25,6 @@
import { Children, Component } from 'react'
import { View } from '@instructure/ui-view/latest'
-import { hasVisibleChildren } from '@instructure/ui-a11y-utils'
import { isActiveElement, findFocusable } from '@instructure/ui-dom-utils'
import {
getElementType,
@@ -41,7 +40,7 @@ import { withStyleNew } from '@instructure/emotion'
import generateStyle from './styles.js'
import { allowedProps } from './props.js'
-import type { LinkProps, LinkState, LinkStyleProps } from './props'
+import type { LinkProps, LinkState } from './props'
import type { ViewOwnProps } from '@instructure/ui-view/latest'
@@ -69,31 +68,11 @@ class Link extends Component {
ref: Element | null = null
componentDidMount() {
- this.props.makeStyles?.(this.makeStyleProps())
+ this.props.makeStyles?.()
}
componentDidUpdate() {
- this.props.makeStyles?.(this.makeStyleProps())
- }
-
- makeStyleProps = (): LinkStyleProps & {
- variant?: LinkProps['variant']
- size?: LinkProps['size']
- } => {
- const { variant, size: sizeProp } = this.props
-
- let size = sizeProp
-
- if ((variant === 'inline' || variant === 'standalone') && !sizeProp) {
- size = 'medium'
- }
-
- return {
- containsTruncateText: this.containsTruncateText,
- hasVisibleChildren: this.hasVisibleChildren,
- variant,
- size
- }
+ this.props.makeStyles?.()
}
handleElementRef = (el: Element | null) => {
@@ -180,10 +159,6 @@ class Link extends Component {
return findFocusable(this.ref)
}
- get hasVisibleChildren() {
- return hasVisibleChildren(this.props.children)
- }
-
get role() {
const { role, forceButtonRole, onClick } = this.props
diff --git a/packages/ui-link/src/Link/v2/props.ts b/packages/ui-link/src/Link/v2/props.ts
index f1378e6545..2286dbfcd0 100644
--- a/packages/ui-link/src/Link/v2/props.ts
+++ b/packages/ui-link/src/Link/v2/props.ts
@@ -136,11 +136,6 @@ type LinkOwnProps = {
size?: 'small' | 'medium' | 'large'
}
-export type LinkStyleProps = {
- containsTruncateText: boolean
- hasVisibleChildren: boolean
-}
-
type PropKeys = keyof LinkOwnProps
type AllowedPropKeys = Readonly>
diff --git a/packages/ui-link/src/Link/v2/styles.ts b/packages/ui-link/src/Link/v2/styles.ts
index 20e0f758f8..2bef0f877f 100644
--- a/packages/ui-link/src/Link/v2/styles.ts
+++ b/packages/ui-link/src/Link/v2/styles.ts
@@ -23,7 +23,8 @@
*/
import { NewComponentTypes, SharedTokens } from '@instructure/ui-themes'
-import type { LinkProps, LinkStyle, LinkStyleProps } from './props'
+import { hasVisibleChildren } from '@instructure/ui-a11y-utils'
+import type { LinkProps, LinkStyle } from './props'
import {
calcFocusOutlineStyles,
calcSpacingFromShorthand
@@ -36,31 +37,27 @@ import {
* @param {Object} componentTheme The theme variable object.
* @param {Object} props the props of the component, the style is applied to
* @param {Object} sharedTokens Shared token object that stores common values for the theme.
- * @param {Object} state the state of the component, the style is applied to
* @return {Object} The final style object, which will be used in the component
*/
const generateStyle = (
componentTheme: ReturnType,
props: LinkProps,
- sharedTokens: SharedTokens,
- state: Partial<
- LinkStyleProps & {
- variant?: 'inline' | 'standalone'
- size?: 'small' | 'medium' | 'large'
- }
- > = {}
+ sharedTokens: SharedTokens
): LinkStyle => {
const {
renderIcon,
iconPlacement = 'start', // TODO workaround needed for react 19 where defaultprops doesn't apply for some reasong
color,
- margin
+ margin,
+ variant,
+ children
} = props
- // Get size and variant from state (passed from makeStyleProps) or fall back to props
- const size = state.size ?? props.size
- const variant = state.variant ?? props.variant
- const hasVisibleChildren = state.hasVisibleChildren ?? false
+ const size =
+ !props.size && (variant === 'inline' || variant === 'standalone')
+ ? 'medium'
+ : props.size
+ const hasVisibleContent = hasVisibleChildren(children)
const isInverseStyle = color === 'link-inverse'
const inlineLinkSizeStyles = {
@@ -152,7 +149,7 @@ const generateStyle = (
// If icon is present, use flex to align icon with text
// Use 'flex' for standalone variant (block-level), 'inline-flex' for inline variant
...(renderIcon &&
- hasVisibleChildren && {
+ hasVisibleContent && {
display: variant === 'standalone' ? 'flex' : 'inline-flex',
alignItems: 'baseline'
}),
diff --git a/packages/ui-table/src/Table/v2/Row/index.tsx b/packages/ui-table/src/Table/v2/Row/index.tsx
index 8067a7b8ec..27777c8fbf 100644
--- a/packages/ui-table/src/Table/v2/Row/index.tsx
+++ b/packages/ui-table/src/Table/v2/Row/index.tsx
@@ -23,9 +23,9 @@
*/
import {
- Component,
+ forwardRef,
+ useContext,
Children,
- ContextType,
isValidElement,
type ReactElement
} from 'react'
@@ -33,7 +33,7 @@ import {
import { omitProps, safeCloneElement } from '@instructure/ui-react-utils'
import { View } from '@instructure/ui-view/latest'
-import { withStyleNew } from '@instructure/emotion'
+import { useStyleNew } from '@instructure/emotion'
import generateStyle from './styles.js'
@@ -47,60 +47,52 @@ parent: Table
id: Table.Row
---
**/
-@withStyleNew(generateStyle)
-class Row extends Component {
- static displayName = 'Row'
- static readonly componentId = 'Table.Row'
- static contextType = TableContext
- declare context: ContextType
- static allowedProps = allowedProps
+const Row = forwardRef((props, ref) => {
+ const { children, setHoverStateTo, themeOverride } = props
+ const { isStacked, hover, headers } = useContext(TableContext)
- static defaultProps = {
- children: null
- }
-
- componentDidMount() {
- this.props.makeStyles?.({
- isStacked: this.context.isStacked,
- hover: this.context.hover
- })
- }
+ const styles = useStyleNew({
+ generateStyle,
+ themeOverride,
+ params: { isStacked, hover, setHoverStateTo },
+ componentId: 'TableRow',
+ displayName: 'Row'
+ })
- componentDidUpdate() {
- this.props.makeStyles?.({
- isStacked: this.context.isStacked,
- hover: this.context.hover
- })
+ const handleElementRef = (el: HTMLElement | null) => {
+ if (typeof ref === 'function') {
+ ref(el)
+ } else if (ref) {
+ const refObject = ref as React.MutableRefObject
+ refObject.current = el
+ }
}
- render() {
- const { children, styles } = this.props
- const isStacked = this.context.isStacked
- const headers = this.context.headers
+ return (
+
+ {Children.toArray(children)
+ .filter(Boolean)
+ .map((child, index) => {
+ if (isValidElement(child)) {
+ return safeCloneElement(child, {
+ key: (child as ReactElement).props.name,
+ // used by `Cell` to render its column title in `stacked` layout
+ header: headers && headers[index]
+ })
+ }
+ return child
+ })}
+
+ )
+})
- return (
-
- {Children.toArray(children)
- .filter(Boolean)
- .map((child, index) => {
- if (isValidElement(child)) {
- return safeCloneElement(child, {
- key: (child as ReactElement).props.name,
- // used by `Cell` to render its column title in `stacked` layout
- header: headers && headers[index]
- })
- }
- return child
- })}
-
- )
- }
-}
+Row.displayName = 'Row'
export default Row
export { Row }
diff --git a/packages/ui-table/src/Table/v2/Row/props.ts b/packages/ui-table/src/Table/v2/Row/props.ts
index dae2bffd7c..b362874cee 100644
--- a/packages/ui-table/src/Table/v2/Row/props.ts
+++ b/packages/ui-table/src/Table/v2/Row/props.ts
@@ -24,7 +24,7 @@
import React from 'react'
import type { OtherHTMLAttributes } from '@instructure/shared-types'
-import type { ComponentStyle, WithStyleProps } from '@instructure/emotion'
+import type { ComponentStyle, NewThemeOverrideProp } from '@instructure/emotion'
import type { NewComponentTypes } from '@instructure/ui-themes'
type TableRowOwnProps = {
@@ -57,7 +57,7 @@ type PropKeys = keyof TableRowOwnProps
type AllowedPropKeys = Readonly>
type TableRowProps = TableRowOwnProps &
- WithStyleProps, TableRowStyle> &
+ NewThemeOverrideProp> &
OtherHTMLAttributes
type TableRowStyle = ComponentStyle<'row'>
diff --git a/packages/ui-table/src/Table/v2/Row/styles.ts b/packages/ui-table/src/Table/v2/Row/styles.ts
index 75131b462f..aac43b1fb4 100644
--- a/packages/ui-table/src/Table/v2/Row/styles.ts
+++ b/packages/ui-table/src/Table/v2/Row/styles.ts
@@ -22,7 +22,7 @@
* SOFTWARE.
*/
-import type { NewComponentTypes, SharedTokens } from '@instructure/ui-themes'
+import type { NewComponentTypes } from '@instructure/ui-themes'
import type { TableRowProps, TableRowStyle } from './props'
/**
@@ -31,18 +31,18 @@ import type { TableRowProps, TableRowStyle } from './props'
* ---
* Generates the style object from the theme and provided additional information
* @param {Object} componentTheme The theme variable object.
- * @param {Object} props the props of the component, the style is applied to
- * @param {Object} _sharedTokens Shared token object (not used in this component)
- * @param {Object} extraArgs the state of the component, the style is applied to
+ * @param {Object} params the props and Table context values the style depends on
* @return {Object} The final style object, which will be used in the component
*/
const generateStyle = (
componentTheme: ReturnType,
- props: TableRowProps,
- _sharedTokens: SharedTokens,
- extraArgs: { isStacked: boolean; hover: boolean }
+ params: {
+ isStacked: boolean
+ hover: boolean
+ setHoverStateTo: TableRowProps['setHoverStateTo']
+ }
): TableRowStyle => {
- const { setHoverStateTo } = props
+ const { isStacked, hover, setHoverStateTo } = params
const hoverStyles = {
borderLeftColor: componentTheme.hoverBorderColor,
@@ -62,13 +62,13 @@ const generateStyle = (
borderBottomWidth: '0.0625rem',
borderBottomColor: componentTheme.borderColor,
- ...((setHoverStateTo ?? extraArgs.hover) && {
+ ...((setHoverStateTo ?? hover) && {
borderLeft: '0.1875rem solid transparent',
borderRight: '0.1875rem solid transparent',
...(setHoverStateTo === true ? hoverStyles : { '&:hover': hoverStyles })
}),
- ...(extraArgs.isStacked && {
+ ...(isStacked && {
padding: `${componentTheme.paddingVertical} ${componentTheme.paddingHorizontal}`
})
}
diff --git a/regression-test/src/app/breadcrumb/page.tsx b/regression-test/src/app/breadcrumb/page.tsx
index 228a0373c1..8f13ad32ae 100644
--- a/regression-test/src/app/breadcrumb/page.tsx
+++ b/regression-test/src/app/breadcrumb/page.tsx
@@ -90,6 +90,15 @@ export default function BreadcrumbPage() {
New Question
+
+
+ English literature 204
+ } href="#">
+ Second term modules
+
+ Current lesson overview
+
+
)
}