diff --git a/.changeset/dull-dingos-collect.md b/.changeset/dull-dingos-collect.md new file mode 100644 index 00000000000..e10394cfdbf --- /dev/null +++ b/.changeset/dull-dingos-collect.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris-migrator': patch +--- + +Handled `buttonFrom` and `buttonsFrom` functions in `Button` migration diff --git a/.changeset/lovely-rings-enjoy.md b/.changeset/lovely-rings-enjoy.md new file mode 100644 index 00000000000..c0eb42f477b --- /dev/null +++ b/.changeset/lovely-rings-enjoy.md @@ -0,0 +1,15 @@ +--- +'@shopify/polaris': minor +--- + +Added support for subheaders and selection of a range of `IndexTable.Rows` -- See the [With subheaders](https://polaris.shopify.com/components/tables/index-table) example on polaris.shopify.com for how to properly configure +- `IndexTable.Row` + - Added support for setting the `indeterminate` value on the `selected` prop + - Added the `selectionRange` prop to specify a range of other consecutive, related rows selected when the row is selected + - Added the `rowType` prop to indicate the relationship or role of the row's contents (defaults to `data`, `subheader` renders the row to look and behave like the table header row) +Added support for setting accessibility attributes on `IndexTable.Cell` +- `IndexTable.Cell` + - Added the `as` prop to support rendering the cell as a `th` element if it is serving as a subheading cell + - Added support for the `headers` attribute to manually associate all headers when the cell is described by more than its column heading + - Added support for the `colSpan` attribute to specify the number of the columns that the cell element should extend to + - Added support for the `scope` attribute to indicate whether the `th` is a header for a column, row, or group of columns or rows diff --git a/.changeset/lucky-wombats-push.md b/.changeset/lucky-wombats-push.md new file mode 100644 index 00000000000..ca2b7fddbce --- /dev/null +++ b/.changeset/lucky-wombats-push.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris': minor +--- + +Add new `IndexFiltersManager` for allowing disabling of Page Header actions when in Filtering or EditingColumns mode diff --git a/.changeset/silly-years-search.md b/.changeset/silly-years-search.md new file mode 100644 index 00000000000..013a6e4abe2 --- /dev/null +++ b/.changeset/silly-years-search.md @@ -0,0 +1,5 @@ +--- +'polaris.shopify.com': patch +--- + +Updated logic for rendering `color` custom property previews in `TokenList` diff --git a/.changeset/ten-snakes-play.md b/.changeset/ten-snakes-play.md new file mode 100644 index 00000000000..02db933b9ee --- /dev/null +++ b/.changeset/ten-snakes-play.md @@ -0,0 +1,5 @@ +--- +'polaris.shopify.com': patch +--- + +Updated copy url to change browser url diff --git a/polaris-migrator/src/migrations/v12-react-update-button-component/tests/v12-react-update-button-from-function.input.tsx b/polaris-migrator/src/migrations/v12-react-update-button-component/tests/v12-react-update-button-from-function.input.tsx new file mode 100644 index 00000000000..960685f199c --- /dev/null +++ b/polaris-migrator/src/migrations/v12-react-update-button-component/tests/v12-react-update-button-from-function.input.tsx @@ -0,0 +1,28 @@ +import {buttonFrom, buttonsFrom} from '@shopify/polaris'; + +const myButtonFrom = buttonFrom; + +export function App() { + const primaryFooterActionMarkup = buttonFrom( + {content: 'Edit', onAction: () => {}}, + { + primary: true, + }, + ); + + const myButtonMarkup = myButtonFrom( + {content: 'Edit', onAction: () => {}}, + { + primary: true, + }, + ); + + const multipleButtonsMarkup = buttonsFrom( + {content: 'Edit', onAction: () => {}}, + { + primary: true, + }, + ); + + return primaryFooterActionMarkup; +} diff --git a/polaris-migrator/src/migrations/v12-react-update-button-component/tests/v12-react-update-button-from-function.output.tsx b/polaris-migrator/src/migrations/v12-react-update-button-component/tests/v12-react-update-button-from-function.output.tsx new file mode 100644 index 00000000000..44ec8bd6068 --- /dev/null +++ b/polaris-migrator/src/migrations/v12-react-update-button-component/tests/v12-react-update-button-from-function.output.tsx @@ -0,0 +1,34 @@ +import {buttonFrom} from '@shopify/polaris'; + +const myButtonFrom = + /* polaris-migrator: Unable to migrate the following expression. Please upgrade manually. */ + buttonFrom; + +export function App() { + const primaryFooterActionMarkup = + /* polaris-migrator: Unable to migrate the following expression. Please upgrade manually. */ + buttonFrom( + {content: 'Edit', onAction: () => {}}, + { + primary: true, + }, + ); + + const myButtonMarkup = myButtonFrom( + {content: 'Edit', onAction: () => {}}, + { + primary: true, + }, + ); + + const multipleButtonsMarkup = + /* polaris-migrator: Unable to migrate the following expression. Please upgrade manually. */ + buttonsFrom( + {content: 'Edit', onAction: () => {}}, + { + primary: true, + }, + ); + + return primaryFooterActionMarkup; +} diff --git a/polaris-migrator/src/migrations/v12-react-update-button-component/transform.ts b/polaris-migrator/src/migrations/v12-react-update-button-component/transform.ts index fc6f5ff0968..a3736f493b7 100644 --- a/polaris-migrator/src/migrations/v12-react-update-button-component/transform.ts +++ b/polaris-migrator/src/migrations/v12-react-update-button-component/transform.ts @@ -22,17 +22,27 @@ export default function transformer( ) { const source = j(fileInfo.source); - // If `Button` component name is not imported, exit + // If `Button` component name or `buttonFrom` function is not imported, exit if ( !( hasImportDeclaration(j, source, '@shopify/polaris') && (hasImportSpecifier(j, source, 'Button', '@shopify/polaris') || - hasImportSpecifier(j, source, 'ButtonProps', '@shopify/polaris')) + hasImportSpecifier(j, source, 'ButtonProps', '@shopify/polaris') || + hasImportSpecifier(j, source, 'buttonFrom', '@shopify/polaris') || + hasImportSpecifier(j, source, 'buttonsFrom', '@shopify/polaris')) ) ) { return fileInfo.source; } + const localFunctionName = + getImportSpecifierName(j, source, 'buttonFrom', '@shopify/polaris') || + 'buttonFrom'; + + const localFunctionPluralName = + getImportSpecifierName(j, source, 'buttonsFrom', '@shopify/polaris') || + 'buttonsFrom'; + const localElementName = getImportSpecifierName(j, source, 'Button', '@shopify/polaris') || 'Button'; @@ -308,7 +318,9 @@ export default function transformer( .filter( (path) => path.node.name === localElementName || - path.node.name === localElementTypeName, + path.node.name === localElementTypeName || + path.node.name === localFunctionName || + path.node.name === localFunctionPluralName, ) .forEach((path) => { if (path.node.type !== 'Identifier') return; diff --git a/polaris-react/src/components/AppProvider/AppProvider.tsx b/polaris-react/src/components/AppProvider/AppProvider.tsx index 7b96a776461..1339b2ef896 100644 --- a/polaris-react/src/components/AppProvider/AppProvider.tsx +++ b/polaris-react/src/components/AppProvider/AppProvider.tsx @@ -16,6 +16,7 @@ import { ScrollLockManager, ScrollLockManagerContext, } from '../../utilities/scroll-lock-manager'; +import {IndexFiltersManager} from '../../utilities/index-filters'; import { StickyManager, StickyManagerContext, @@ -167,7 +168,7 @@ export class AppProvider extends Component { - {children} + {children} diff --git a/polaris-react/src/components/Grid/Grid.tsx b/polaris-react/src/components/Grid/Grid.tsx index ff3b6db103b..7d7f5c7f492 100644 --- a/polaris-react/src/components/Grid/Grid.tsx +++ b/polaris-react/src/components/Grid/Grid.tsx @@ -20,7 +20,9 @@ type Gap = { export interface GridProps { /** * Set grid-template-areas - * @deprecated Use nested layout components instead + * @deprecated To avoid a11y issues, nest layout components in individual grid + * cells instead. See: + * https://polaris.shopify.com/components/layout-and-structure */ areas?: Areas; /* Number of columns */ diff --git a/polaris-react/src/components/Grid/components/Cell/Cell.tsx b/polaris-react/src/components/Grid/components/Cell/Cell.tsx index ede3dd5d8a0..802c9c48b89 100644 --- a/polaris-react/src/components/Grid/components/Cell/Cell.tsx +++ b/polaris-react/src/components/Grid/components/Cell/Cell.tsx @@ -26,7 +26,9 @@ interface Columns { export interface CellProps { /** * Set grid-template-areas - * @deprecated Use nested layout components instead + * @deprecated To avoid a11y issues, nest layout components in individual grid + * cells instead. See: + * https://polaris.shopify.com/components/layout-and-structure */ area?: string; column?: Cell; diff --git a/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx b/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx index cd49d87b73f..f30b136577e 100644 --- a/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx +++ b/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx @@ -10,10 +10,18 @@ import { RangeSlider, TextField, Card, + Page, + Badge, + Button, + useSetIndexFiltersMode, IndexFiltersMode, } from '@shopify/polaris'; +import { + ViewMinor, + DeleteMinor, + MobileVerticalDotsMajor, +} from '@shopify/polaris-icons'; -import {useSetIndexFiltersMode} from './hooks'; import type {IndexFiltersProps} from './IndexFilters'; export default { @@ -95,7 +103,11 @@ function Table() { ); } -function BasicExample(props?: Partial) { +function BasicExample( + props?: Partial & { + withFilteringByDefault?: boolean; + }, +) { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const [itemStrings, setItemStrings] = useState([ @@ -184,7 +196,9 @@ function BasicExample(props?: Partial) { {label: 'Total', value: 'total desc', directionLabel: 'Descending'}, ]; const [sortSelected, setSortSelected] = useState(['order asc']); - const {mode, setMode} = useSetIndexFiltersMode(); + const {mode, setMode} = useSetIndexFiltersMode( + props?.withFilteringByDefault ? IndexFiltersMode.Filtering : undefined, + ); const onHandleCancel = () => {}; const onHandleSave = async () => { @@ -385,6 +399,10 @@ export function Default() { return ; } +export function WithFilteringByDefault() { + return ; +} + export function WithoutKeyboardShortcuts() { return ; } @@ -675,292 +693,6 @@ export function WithPinnedFilters() { } } -export function WithNoPinnedAndPrefilledFilters() { - const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - const [itemStrings, setItemStrings] = useState([ - 'All', - 'Unpaid', - 'Open', - 'Closed', - 'Local delivery', - 'Local pickup', - ]); - const deleteView = (index: number) => { - const newItemStrings = [...itemStrings]; - newItemStrings.splice(index, 1); - setItemStrings(newItemStrings); - setSelected(0); - }; - - const duplicateView = async (name: string) => { - setItemStrings([...itemStrings, name]); - setSelected(itemStrings.length); - await sleep(1); - return true; - }; - - const tabs: TabProps[] = itemStrings.map((item, index) => ({ - content: item, - index, - onAction: () => {}, - id: `${item}-${index}`, - isLocked: index === 0, - actions: - index === 0 - ? [] - : [ - { - type: 'rename', - onAction: () => {}, - onPrimaryAction: async (value: string) => { - const newItemsStrings = tabs.map((item, idx) => { - if (idx === index) { - return value; - } - return item.content; - }); - await sleep(1); - setItemStrings(newItemsStrings); - return true; - }, - }, - { - type: 'duplicate', - onPrimaryAction: async (name) => { - await sleep(1); - duplicateView(name); - return true; - }, - }, - { - type: 'edit', - }, - { - type: 'delete', - onPrimaryAction: async (id: string) => { - await sleep(1); - deleteView(index); - return true; - }, - }, - ], - })); - const [selected, setSelected] = useState(0); - const onCreateNewView = async (value: string) => { - await sleep(500); - setItemStrings([...itemStrings, value]); - setSelected(itemStrings.length); - return true; - }; - const sortOptions: IndexFiltersProps['sortOptions'] = [ - {label: 'Order', value: 'order asc', directionLabel: 'Ascending'}, - {label: 'Order', value: 'order desc', directionLabel: 'Descending'}, - {label: 'Customer', value: 'customer asc', directionLabel: 'A-Z'}, - {label: 'Customer', value: 'customer desc', directionLabel: 'Z-A'}, - {label: 'Date', value: 'date asc', directionLabel: 'A-Z'}, - {label: 'Date', value: 'date desc', directionLabel: 'Z-A'}, - {label: 'Total', value: 'total asc', directionLabel: 'Ascending'}, - {label: 'Total', value: 'total desc', directionLabel: 'Descending'}, - ]; - const [sortSelected, setSortSelected] = useState(['order asc']); - const {mode, setMode} = useSetIndexFiltersMode(IndexFiltersMode.Filtering); - const onHandleCancel = () => {}; - - const onHandleSave = async () => { - await sleep(1); - return true; - }; - - const primaryAction: IndexFiltersProps['primaryAction'] = - selected === 0 - ? { - type: 'save-as', - onAction: onCreateNewView, - disabled: false, - loading: false, - } - : { - type: 'save', - onAction: onHandleSave, - disabled: false, - loading: false, - }; - const [accountStatus, setAccountStatus] = useState([ - 'enabled', - ]); - const [moneySpent, setMoneySpent] = useState(null); - const [taggedWith, setTaggedWith] = useState('Returning customer'); - const [queryValue, setQueryValue] = useState(''); - - const handleAccountStatusChange = useCallback( - (value) => setAccountStatus(value), - [], - ); - const handleMoneySpentChange = useCallback( - (value) => setMoneySpent(value), - [], - ); - const handleTaggedWithChange = useCallback( - (value) => setTaggedWith(value), - [], - ); - const handleFiltersQueryChange = useCallback( - (value) => setQueryValue(value), - [], - ); - const handleAccountStatusRemove = useCallback( - () => setAccountStatus(null), - [], - ); - const handleMoneySpentRemove = useCallback(() => setMoneySpent(null), []); - const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); - const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); - const handleFiltersClearAll = useCallback(() => { - handleAccountStatusRemove(); - handleMoneySpentRemove(); - handleTaggedWithRemove(); - handleQueryValueRemove(); - }, [ - handleAccountStatusRemove, - handleMoneySpentRemove, - handleQueryValueRemove, - handleTaggedWithRemove, - ]); - - const filters = [ - { - key: 'accountStatus', - label: 'Account status', - filter: ( - - ), - pinned: false, - }, - { - key: 'taggedWith', - label: 'Tagged with', - filter: ( - - ), - pinned: false, - }, - { - key: 'moneySpent', - label: 'Money spent', - filter: ( - - ), - }, - ]; - - const appliedFilters: IndexFiltersProps['appliedFilters'] = []; - if (!isEmpty(accountStatus)) { - const key = 'accountStatus'; - appliedFilters.push({ - key, - label: disambiguateLabel(key, accountStatus), - onRemove: handleAccountStatusRemove, - }); - } - if (!isEmpty(moneySpent)) { - const key = 'moneySpent'; - appliedFilters.push({ - key, - label: disambiguateLabel(key, moneySpent), - onRemove: handleMoneySpentRemove, - }); - } - if (!isEmpty(taggedWith)) { - const key = 'taggedWith'; - appliedFilters.push({ - key, - label: disambiguateLabel(key, taggedWith), - onRemove: handleTaggedWithRemove, - }); - } - - return ( - - {}} - onSort={setSortSelected} - primaryAction={primaryAction} - cancelAction={{ - onAction: onHandleCancel, - disabled: false, - loading: false, - }} - tabs={tabs} - selected={selected} - onSelect={setSelected} - canCreateNewView - onCreateNewView={onCreateNewView} - filters={filters} - appliedFilters={appliedFilters} - onClearAll={handleFiltersClearAll} - mode={mode} - setMode={setMode} - /> - - - ); - - function disambiguateLabel(key, value) { - switch (key) { - case 'moneySpent': - return `Money spent is between $${value[0]} and $${value[1]}`; - case 'taggedWith': - return `Tagged with ${value}`; - case 'accountStatus': - return value.map((val) => `Customer ${val}`).join(', '); - default: - return value; - } - } - - function isEmpty(value) { - if (Array.isArray(value)) { - return value.length === 0; - } else { - return value === '' || value == null; - } - } -} - export function Disabled() { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -1390,3 +1122,107 @@ export function WithQueryFieldAndFiltersHidden() { ); } + +export function WrappedInAPage() { + return ( + Paid} + subtitle="Perfect for any pet" + compactTitle + primaryAction={{content: 'Save'}} + secondaryActions={[ + { + content: 'Delete', + destructive: true, + icon: DeleteMinor, + accessibilityLabel: 'Delete action label', + onAction: () => console.log('Delete action'), + }, + { + content: 'View on your store', + icon: ViewMinor, + onAction: () => console.log('View on your store action'), + }, + ]} + actionGroups={[ + { + title: 'Promote', + icon: MobileVerticalDotsMajor, + actions: [ + { + content: 'Share on Facebook', + accessibilityLabel: 'Individual action label', + onAction: () => console.log('Share on Facebook action'), + }, + ], + }, + ]} + pagination={{ + hasPrevious: true, + hasNext: true, + }} + > + + + ); +} + +export function WrappedInAPageWithCustomActions() { + const {mode} = useSetIndexFiltersMode(); + const shouldDisableAction = mode !== IndexFiltersMode.Default; + return ( + Paid} + subtitle="Perfect for any pet" + compactTitle + primaryAction={ + + } + secondaryActions={ + + } + actionGroups={[ + { + title: 'Promote', + icon: MobileVerticalDotsMajor, + actions: [ + { + content: 'Share on Facebook', + accessibilityLabel: 'Individual action label', + onAction: () => console.log('Share on Facebook action'), + }, + ], + }, + ]} + pagination={{ + hasPrevious: true, + hasNext: true, + }} + > + + + ); +} diff --git a/polaris-react/src/components/IndexFilters/IndexFilters.tsx b/polaris-react/src/components/IndexFilters/IndexFilters.tsx index 4766a1df6d8..d362c2ce8c9 100644 --- a/polaris-react/src/components/IndexFilters/IndexFilters.tsx +++ b/polaris-react/src/components/IndexFilters/IndexFilters.tsx @@ -13,6 +13,7 @@ import type {FiltersProps} from '../Filters'; import {Tabs} from '../Tabs'; import type {TabsProps} from '../Tabs'; import {useBreakpoints} from '../../utilities/breakpoints'; +import {IndexFiltersMode} from '../../utilities/index-filters'; import {useIsSticky} from './hooks'; import { @@ -26,7 +27,6 @@ import type { IndexFiltersCancelAction, SortButtonChoice, } from './types'; -import {IndexFiltersMode} from './types'; import styles from './IndexFilters.scss'; const DEFAULT_IGNORED_TAGS = ['INPUT', 'SELECT', 'TEXTAREA']; diff --git a/polaris-react/src/components/IndexFilters/hooks/index.ts b/polaris-react/src/components/IndexFilters/hooks/index.ts index 5273cb5d3a9..d2acec233ff 100644 --- a/polaris-react/src/components/IndexFilters/hooks/index.ts +++ b/polaris-react/src/components/IndexFilters/hooks/index.ts @@ -1,2 +1 @@ -export * from './useSetIndexFiltersMode'; export * from './useIsSticky'; diff --git a/polaris-react/src/components/IndexFilters/hooks/useIsSticky/tests/useIsSticky.test.tsx b/polaris-react/src/components/IndexFilters/hooks/useIsSticky/tests/useIsSticky.test.tsx index 3903f388d49..b1d2211b70e 100644 --- a/polaris-react/src/components/IndexFilters/hooks/useIsSticky/tests/useIsSticky.test.tsx +++ b/polaris-react/src/components/IndexFilters/hooks/useIsSticky/tests/useIsSticky.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {intersectionObserver} from '@shopify/jest-dom-mocks'; import {mountWithApp} from 'tests/utilities'; -import {IndexFiltersMode} from '../../../types'; +import {IndexFiltersMode} from '../../../../../utilities/index-filters'; import {useIsSticky} from '..'; interface Props { diff --git a/polaris-react/src/components/IndexFilters/hooks/useIsSticky/useIsSticky.ts b/polaris-react/src/components/IndexFilters/hooks/useIsSticky/useIsSticky.ts index 22fc3165db1..8e07d4c3ab0 100644 --- a/polaris-react/src/components/IndexFilters/hooks/useIsSticky/useIsSticky.ts +++ b/polaris-react/src/components/IndexFilters/hooks/useIsSticky/useIsSticky.ts @@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from 'react'; import type {RefObject} from 'react'; import {debounce} from '../../../../utilities/debounce'; -import type {IndexFiltersMode} from '../../types'; +import type {IndexFiltersMode} from '../../../../utilities/index-filters'; const DEBOUNCE_PERIOD = 250; diff --git a/polaris-react/src/components/IndexFilters/hooks/useSetIndexFiltersMode/index.ts b/polaris-react/src/components/IndexFilters/hooks/useSetIndexFiltersMode/index.ts deleted file mode 100644 index a6211e533f6..00000000000 --- a/polaris-react/src/components/IndexFilters/hooks/useSetIndexFiltersMode/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useSetIndexFiltersMode'; diff --git a/polaris-react/src/components/IndexFilters/hooks/useSetIndexFiltersMode/useSetIndexFiltersMode.tsx b/polaris-react/src/components/IndexFilters/hooks/useSetIndexFiltersMode/useSetIndexFiltersMode.tsx deleted file mode 100644 index d3aaf23ec1b..00000000000 --- a/polaris-react/src/components/IndexFilters/hooks/useSetIndexFiltersMode/useSetIndexFiltersMode.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {useState} from 'react'; - -import {IndexFiltersMode} from '../../types'; - -export function useSetIndexFiltersMode( - defaultMode: IndexFiltersMode = IndexFiltersMode.Default, -) { - const [mode, setMode] = useState(defaultMode); - - return {mode, setMode}; -} diff --git a/polaris-react/src/components/IndexFilters/tests/IndexFilters.test.tsx b/polaris-react/src/components/IndexFilters/tests/IndexFilters.test.tsx index db9a05c422b..1eb9e2b6373 100644 --- a/polaris-react/src/components/IndexFilters/tests/IndexFilters.test.tsx +++ b/polaris-react/src/components/IndexFilters/tests/IndexFilters.test.tsx @@ -5,7 +5,8 @@ import {matchMedia} from '@shopify/jest-dom-mocks'; import {Tabs} from '../../Tabs'; import {Filters} from '../../Filters'; -import {IndexFilters, IndexFiltersMode} from '..'; +import {IndexFilters} from '..'; +import {IndexFiltersMode} from '../../../utilities/index-filters'; import type {IndexFiltersProps} from '../IndexFilters'; import {SearchFilterButton, SortButton, UpdateButtons} from '../components'; diff --git a/polaris-react/src/components/IndexFilters/types.ts b/polaris-react/src/components/IndexFilters/types.ts index 7ef04c71daa..d1cd35e299b 100644 --- a/polaris-react/src/components/IndexFilters/types.ts +++ b/polaris-react/src/components/IndexFilters/types.ts @@ -19,9 +19,3 @@ export interface IndexFiltersCancelAction { disabled?: boolean; loading?: boolean; } - -export enum IndexFiltersMode { - Default = 'DEFAULT', - Filtering = 'FILTERING', - EditingColumns = 'EDITING_COLUMNS', -} diff --git a/polaris-react/src/components/IndexTable/IndexTable.scss b/polaris-react/src/components/IndexTable/IndexTable.scss index d8e9f836c89..835e75502a4 100644 --- a/polaris-react/src/components/IndexTable/IndexTable.scss +++ b/polaris-react/src/components/IndexTable/IndexTable.scss @@ -193,6 +193,18 @@ $loading-panel-height: 53px; background-color: var(--p-color-bg-subdued); color: var(--p-color-text-subdued); } + + // stylelint-disable-next-line selector-max-class -- generated by polaris-migrator DO NOT COPY + &.TableRow-subheader { + // stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY + &, + .TableCell:first-child, + .TableCell-first, + .TableCell-first + .TableCell, + .TableCell:last-child { + background-color: var(--p-color-bg-subdued); + } + } } &.TableRow-selected { @@ -205,18 +217,31 @@ $loading-panel-height: 53px; .TableCell-first + .TableCell { background-color: var(--p-color-bg-primary-subdued-selected); } + + /* stylelint-disable-next-line selector-max-class -- generated by polaris-migrator DO NOT COPY */ + &.TableRow-subheader { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ + &, + .TableCell:first-child, + .TableCell-first, + .TableCell-first + .TableCell, + .TableCell:last-child { + background-color: var(--p-color-bg-subdued); + } + } } // NOTE: `-hovered` must come after `-selected` so elements don't appear to lose their interactivity when selected. - &.TableRow-hovered { - // stylelint-disable selector-max-class, selector-max-specificity, selector-max-combinators -- status hover styles + &.TableRow-hovered:not(.TableRow-disabled) { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ &, .TableCell-first, .TableCell-first + .TableCell { background-color: var(--p-color-bg-hover); } - + /* stylelint-disable-next-line selector-max-class, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ &.statusCritical { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ &, .TableCell-first, .TableCell-first + .TableCell { @@ -224,33 +249,36 @@ $loading-panel-height: 53px; } } + /* stylelint-disable-next-line selector-max-class, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ &.statusSubdued { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ &, .TableCell-first, .TableCell-first + .TableCell { background-color: var(--p-color-bg-subdued-hover); } } - + /* stylelint-disable-next-line selector-max-class, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ &.statusSuccess { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ &, .TableCell-first, .TableCell-first + .TableCell { background-color: var(--p-color-bg-success-subdued-hover); } } - + /* stylelint-disable-next-line selector-max-class, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ &.TableRow-subdued { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ &, .TableCell-first, .TableCell-first + .TableCell { background-color: var(--p-color-bg-subdued-hover); } } - // stylelint-enable selector-max-class, selector-max-specificity, selector-max-combinators -- status hover styles } - // stylelint-disable-next-line selector-max-class -- generated by polaris-migrator DO NOT COPY + /* stylelint-disable-next-line selector-max-class -- generated by polaris-migrator DO NOT COPY */ &.TableRow-hovered.TableRow-selected { // stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY &, @@ -259,6 +287,18 @@ $loading-panel-height: 53px; .TableCell:last-child { background-color: var(--p-color-bg-primary-subdued-hover); } + + /* stylelint-disable-next-line selector-max-class, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ + &.TableRow-subheader { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ + &, + .TableCell:first-child, + .TableCell-first, + .TableCell-first + .TableCell, + .TableCell:last-child { + background-color: var(--p-color-bg-subdued); + } + } } } diff --git a/polaris-react/src/components/IndexTable/IndexTable.stories.tsx b/polaris-react/src/components/IndexTable/IndexTable.stories.tsx index e170da57088..e0807467670 100644 --- a/polaris-react/src/components/IndexTable/IndexTable.stories.tsx +++ b/polaris-react/src/components/IndexTable/IndexTable.stories.tsx @@ -1,19 +1,26 @@ -import React, {useCallback, useState} from 'react'; +import React, {Fragment, useCallback, useState} from 'react'; import type {ComponentMeta} from '@storybook/react'; -import type {IndexFiltersProps} from '@shopify/polaris'; +import type { + IndexFiltersProps, + IndexTableProps, + IndexTableRowProps, +} from '@shopify/polaris'; import { + Icon, + HorizontalStack, Button, LegacyCard, EmptySearchResult, IndexFilters, useSetIndexFiltersMode, - IndexTable, Link, TextField, Text, useIndexResourceState, } from '@shopify/polaris'; +import {IndexTable} from './IndexTable'; + export default { component: IndexTable, } as ComponentMeta; @@ -1400,7 +1407,7 @@ export function WithFiltering() { (value) => setTaggedWith(value), [], ); - const handleTaggedWithRemove = useCallback(() => setTaggedWith(null), []); + const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); const handleClearAll = useCallback(() => { handleTaggedWithRemove(); @@ -1435,7 +1442,7 @@ export function WithFiltering() { ] : []; - const sortOptions = [ + const sortOptions: IndexFiltersProps['sortOptions'] = [ {label: 'Date', value: 'today asc', directionLabel: 'Ascending'}, {label: 'Date', value: 'today desc', directionLabel: 'Descending'}, ]; @@ -1613,7 +1620,7 @@ export function WithRowStatus() { key={id} selected={selectedResources.includes(id)} position={index} - status={status} + status={status as IndexTableRowProps['status']} > @@ -1959,7 +1966,7 @@ export function WithClickableButtonColumn() { position={index} > - + {location} @@ -2171,7 +2178,7 @@ export function WithAllOfItsElements() { (value) => setTaggedWith(value), [], ); - const handleTaggedWithRemove = useCallback(() => setTaggedWith(null), []); + const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); const handleClearAll = useCallback(() => { handleTaggedWithRemove(); @@ -2227,7 +2234,7 @@ export function WithAllOfItsElements() { ] : []; - const sortOptions = [ + const sortOptions: IndexFiltersProps['sortOptions'] = [ {label: 'Date', value: 'today asc', directionLabel: 'Ascending'}, {label: 'Date', value: 'today desc', directionLabel: 'Descending'}, ]; @@ -2359,7 +2366,8 @@ export function WithAllOfItsElements() { export function WithSortableHeadings() { const [sortIndex, setSortIndex] = useState(0); - const [sortDirection, setSortDirection] = useState('descending'); + const [sortDirection, setSortDirection] = + useState('descending'); const sortToggleLabels = { 0: {ascending: 'A-Z', descending: 'Z-A'}, @@ -2544,7 +2552,8 @@ export function WithSortableHeadings() { export function WithSortableCustomHeadings() { const [sortIndex, setSortIndex] = useState(0); - const [sortDirection, setSortDirection] = useState('descending'); + const [sortDirection, setSortDirection] = + useState('descending'); const sortToggleLabels = { 0: {ascending: 'A-Z', descending: 'Z-A'}, @@ -2849,6 +2858,116 @@ export function WithCustomTooltips() { ); } +export function WithHeadingTooltips() { + const customers = [ + { + id: '3410', + url: '#', + name: 'Mae Jemison', + location: 'Decatur, USA', + orders: 20, + amountSpent: '$2,400', + }, + { + id: '3411', + url: '#', + name: 'Joe Jemison', + location: 'Sydney, AU', + orders: 20, + amountSpent: '$1,400', + }, + { + id: '3412', + url: '#', + name: 'Sam Jemison', + location: 'Decatur, USA', + orders: 20, + amountSpent: '$400', + }, + { + id: '3413', + url: '#', + name: 'Mae Jemison', + location: 'Decatur, USA', + orders: 20, + amountSpent: '$4,300', + }, + { + id: '2563', + url: '#', + name: 'Ellen Ochoa', + location: 'Los Angeles, USA', + orders: 30, + amountSpent: '$140', + }, + ]; + const resourceName = { + singular: 'customer', + plural: 'customers', + }; + + const {selectedResources, allResourcesSelected, handleSelectionChange} = + useIndexResourceState(customers); + + const rowMarkup = customers.map( + ({id, name, location, orders, amountSpent}, index) => ( + + + + {name} + + + {location} + + + {orders} + + + + + {amountSpent} + + + + ), + ); + + return ( + + + {rowMarkup} + + + ); +} + export function WithZebraStriping() { const customers = [ { @@ -3106,7 +3225,7 @@ export function WithZebraStripingAndRowStatus() { key={id} selected={selectedResources.includes(id)} position={index} - status={status} + status={status as IndexTableRowProps['status']} > @@ -3408,7 +3527,7 @@ export function SmallScreenWithAllOfItsElements() { (value) => setTaggedWith(value), [], ); - const handleTaggedWithRemove = useCallback(() => setTaggedWith(null), []); + const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); const handleClearAll = useCallback(() => { handleTaggedWithRemove(); @@ -3464,7 +3583,7 @@ export function SmallScreenWithAllOfItsElements() { ] : []; - const sortOptions = [ + const sortOptions: IndexFiltersProps['sortOptions'] = [ {label: 'Date', value: 'today asc', directionLabel: 'Ascending'}, {label: 'Date', value: 'today desc', directionLabel: 'Descending'}, ]; @@ -3591,109 +3710,208 @@ export function SmallScreenWithAllOfItsElements() { } } -export function WithHeadingTooltips() { - const customers = [ +export function WithSubHeaders() { + const rows = [ { - id: '3410', + id: '3411', url: '#', name: 'Mae Jemison', location: 'Decatur, USA', - orders: 20, + orders: 11, amountSpent: '$2,400', + lastOrderDate: 'May 31, 2023', }, { - id: '3411', + id: '2562', url: '#', - name: 'Joe Jemison', - location: 'Sydney, AU', - orders: 20, - amountSpent: '$1,400', + name: 'Ellen Ochoa', + location: 'Los Angeles, USA', + orders: 30, + amountSpent: '$975', + lastOrderDate: 'May 31, 2023', }, { - id: '3412', + id: '4102', url: '#', - name: 'Sam Jemison', - location: 'Decatur, USA', - orders: 20, - amountSpent: '$400', + name: 'Colm Dillane', + location: 'New York, USA', + orders: 27, + amountSpent: '$2885', + lastOrderDate: 'May 31, 2023', }, { - id: '3413', + id: '2564', url: '#', - name: 'Mae Jemison', - location: 'Decatur, USA', - orders: 20, - amountSpent: '$4,300', + name: 'Al Chemist', + location: 'New York, USA', + orders: 19, + amountSpent: '$1,209', + lastOrderDate: 'April 4, 2023', + disabled: true, }, { id: '2563', url: '#', - name: 'Ellen Ochoa', - location: 'Los Angeles, USA', - orders: 30, - amountSpent: '$140', + name: 'Larry June', + location: 'San Francisco, USA', + orders: 22, + amountSpent: '$1,400', + lastOrderDate: 'March 19, 2023', }, ]; + + const columnHeadings = [ + {title: 'Name', id: 'column-header--name'}, + {title: 'Location', id: 'column-header--location'}, + { + alignment: 'end', + id: 'column-header--order-count', + title: 'Order count', + }, + { + alignment: 'end', + hidden: false, + id: 'column-header--amount-spent', + title: 'Amount spent', + }, + ]; + + const groupRowsBy = (groupKey: string, resolveId: (groupVal) => string) => { + let position = -1; + const groups = rows.reduce((groups, customer) => { + const groupVal = customer[groupKey]; + if (!groups[groupVal]) { + position += 1; + + groups[groupVal] = { + position, + customers: [], + id: resolveId(groupVal), + }; + } + + groups[groupVal].customers.push({ + ...customer, + position: position + 1, + }); + + position += 1; + return groups; + }, {}); + + return groups; + }; + const resourceName = { singular: 'customer', plural: 'customers', }; const {selectedResources, allResourcesSelected, handleSelectionChange} = - useIndexResourceState(customers); + useIndexResourceState(rows, {resourceFilter: ({disabled}) => !disabled}); - const rowMarkup = customers.map( - ({id, name, location, orders, amountSpent}, index) => ( - - - - {name} - - - {location} - - - {orders} - - - - - {amountSpent} - - - - ), + const orders = groupRowsBy( + 'lastOrderDate', + (date) => `last-order-date--${date.replace(',', '').split(' ').join('-')}`, ); + const rowMarkup = Object.keys(orders).map((orderDate, index) => { + const {customers, position, id: subheaderId} = orders[orderDate]; + let selected: IndexTableRowProps['selected'] = false; + + const someCustomersSelected = customers.some(({id}) => + selectedResources.includes(id), + ); + + const allCustomersSelected = customers.every(({id}) => + selectedResources.includes(id), + ); + + if (allCustomersSelected) { + selected = true; + } else if (someCustomersSelected) { + selected = 'indeterminate'; + } + + const selectableRows = rows.filter(({disabled}) => !disabled); + const rowRange: IndexTableRowProps['subHeaderRange'] = [ + selectableRows.findIndex((row) => row.id === customers[0].id), + selectableRows.findIndex( + (row) => row.id === customers[customers.length - 1].id, + ), + ]; + + const disabled = customers.every(({disabled}) => disabled); + + return ( + + + + {`Last order placed: ${orderDate}`} + + + + + + {customers.map( + ( + {id, name, location, orders, amountSpent, position, disabled}, + rowIndex, + ) => { + return ( + + + + {name} + + + {location} + + + {orders} + + + + + {amountSpent} + + + + ); + }, + )} + + ); + }); + return ( {rowMarkup} diff --git a/polaris-react/src/components/IndexTable/IndexTable.tsx b/polaris-react/src/components/IndexTable/IndexTable.tsx index 24c7e358267..db7276dd631 100644 --- a/polaris-react/src/components/IndexTable/IndexTable.tsx +++ b/polaris-react/src/components/IndexTable/IndexTable.tsx @@ -45,6 +45,7 @@ import {ScrollContainer, Cell, Row} from './components'; import styles from './IndexTable.scss'; interface IndexTableHeadingBase { + id?: string; /** * Adjust horizontal alignment of header content. * @default 'start' @@ -65,6 +66,7 @@ interface IndexTableHeadingBase { interface IndexTableHeadingTitleString extends IndexTableHeadingBase { title: string; + id?: string; } interface IndexTableHeadingTitleNode extends IndexTableHeadingBase { @@ -827,6 +829,7 @@ function IndexTableBase({ const headingContent = ( ; + return React.createElement( + as, + {id, colSpan, headers, scope, className}, + children, + ); }); diff --git a/polaris-react/src/components/IndexTable/components/Cell/tests/Cell.test.tsx b/polaris-react/src/components/IndexTable/components/Cell/tests/Cell.test.tsx index c9afc3862ad..b3ef040488b 100644 --- a/polaris-react/src/components/IndexTable/components/Cell/tests/Cell.test.tsx +++ b/polaris-react/src/components/IndexTable/components/Cell/tests/Cell.test.tsx @@ -11,6 +11,38 @@ describe('', () => { expect(cell).toContainReactComponent('td'); }); + it('renders a th element if set on `as` prop', () => { + const cell = mountWithTable(); + + expect(cell).toContainReactComponent('th'); + }); + + it('renders a td element if `as` prop is not set', () => { + const cell = mountWithTable(); + + expect(cell).toContainReactComponent('td'); + }); + + it('forwards the `colSpan` prop', () => { + const cell = mountWithTable(); + + expect(cell.find('td')).toHaveReactProps({colSpan: 3}); + }); + + it('forwards the `scope` prop', () => { + const cell = mountWithTable(); + + expect(cell.find('td')).toHaveReactProps({scope: 'colgroup'}); + }); + + it('forwards the `headers` prop', () => { + const cell = mountWithTable(); + + expect(cell.find('td')).toHaveReactProps({ + headers: 'last-order-date name', + }); + }); + it('applies flushed styles when flush prop is true', () => { const cell = mountWithTable(); diff --git a/polaris-react/src/components/IndexTable/components/Checkbox/Checkbox.tsx b/polaris-react/src/components/IndexTable/components/Checkbox/Checkbox.tsx index dff6974846e..103e8bf1eed 100644 --- a/polaris-react/src/components/IndexTable/components/Checkbox/Checkbox.tsx +++ b/polaris-react/src/components/IndexTable/components/Checkbox/Checkbox.tsx @@ -12,12 +12,23 @@ import sharedStyles from '../../IndexTable.scss'; import styles from './Checkbox.scss'; -export const Checkbox = memo(function Checkbox() { +interface CheckboxProps { + accessibilityLabel?: string; +} + +export const Checkbox = memo(function Checkbox({ + accessibilityLabel, +}: CheckboxProps) { const i18n = useI18n(); const {resourceName} = useIndexValue(); const {itemId, selected, disabled, onInteraction} = useContext(RowContext); const wrapperClassName = classNames(styles.Wrapper); + const label = accessibilityLabel + ? accessibilityLabel + : i18n.translate('Polaris.IndexTable.selectItem', { + resourceName: resourceName.singular, + }); return ( @@ -28,10 +39,8 @@ export const Checkbox = memo(function Checkbox() { onKeyUp={noop} > ', () => { const id = 'id'; const checkbox = mountWithTable(, {rowProps: {id}}); - expect(checkbox).toContainReactComponent(PolarisCheckbox, {id}); + expect(checkbox).toContainReactComponent(PolarisCheckbox, { + id: `Select-${id}`, + }); }); it('renders a Checkbox with a label', () => { @@ -55,6 +57,21 @@ describe('', () => { }); }); + it('renders a Checkbox with a custom label', () => { + const resourceName = {singular: 'Singular', plural: 'Plural'}; + const accessibilityLabel = `Select ${resourceName.singular} who ordered yesterday`; + const checkbox = mountWithTable( + , + { + indexProps: {resourceName}, + }, + ); + + expect(checkbox).toContainReactComponent(PolarisCheckbox, { + label: accessibilityLabel, + }); + }); + it('renders a Checkbox with a label hidden set to true', () => { const checkbox = mountWithTable(); diff --git a/polaris-react/src/components/IndexTable/components/Row/Row.tsx b/polaris-react/src/components/IndexTable/components/Row/Row.tsx index 2a28f9120fc..2b94d6aafc8 100644 --- a/polaris-react/src/components/IndexTable/components/Row/Row.tsx +++ b/polaris-react/src/components/IndexTable/components/Row/Row.tsx @@ -9,20 +9,41 @@ import { import {Checkbox} from '../Checkbox'; import {classNames, variationName} from '../../../../utilities/css'; import {RowContext, RowHoveredContext} from '../../../../utilities/index-table'; +import type {Range} from '../../../../utilities/index-provider/types'; import styles from '../../IndexTable.scss'; +type RowType = 'data' | 'subheader'; type RowStatus = 'success' | 'subdued' | 'critical'; type TableRowElementType = HTMLTableRowElement & HTMLLIElement; export interface RowProps { + /** Table header or data cells */ children: React.ReactNode; + /** A unique identifier for the row */ id: string; - selected?: boolean; + /** Whether the row is selected */ + selected?: boolean | 'indeterminate'; + /** The zero-indexed position of the row. Used for Shift key multi-selection */ position: number; + /** Whether the row should be subdued */ subdued?: boolean; + /** Whether the row should have a status */ status?: RowStatus; + /** Whether the row should be disabled */ disabled?: boolean; + /** A tuple array with the first and last index of the range of other rows that this row describes. All rows in the range are selected when the selection range row is selected. */ + selectionRange?: Range; + /** + * Indicates the relationship or role of the row's contents. A "subheader" row displays the same as the table header. + * @default 'data' */ + rowType?: RowType; + /** Label set on the row's checkbox + * @default "Select {resourceName}" + */ + accessibilityLabel?: string; + /** Callback fired when the row is clicked and contains a data-primary-link */ onNavigation?(id: string): void; + /** Callback fired when the row is clicked. Overrides the default click behaviour. */ onClick?(): void; } @@ -34,6 +55,9 @@ export const Row = memo(function Row({ subdued, status, disabled, + selectionRange, + rowType = 'data', + accessibilityLabel, onNavigation, onClick, }: RowProps) { @@ -48,15 +72,20 @@ export const Row = memo(function Row({ const handleInteraction = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { event.stopPropagation(); + let selectionType = SelectionType.Single; if (('key' in event && event.key !== ' ') || !onSelectionChange) return; - const selectionType = event.nativeEvent.shiftKey - ? SelectionType.Multi - : SelectionType.Single; - onSelectionChange(selectionType, !selected, id, position); + if (event.nativeEvent.shiftKey) { + selectionType = SelectionType.Multi; + } else if (selectionRange) { + selectionType = SelectionType.Range; + } + + const selection: string | Range = selectionRange ?? id; + onSelectionChange(selectionType, !selected, selection, position); }, - [id, onSelectionChange, position, selected], + [id, onSelectionChange, selected, selectionRange, position], ); const contextValue = useMemo( @@ -86,6 +115,7 @@ export const Row = memo(function Row({ const rowClassName = classNames( styles.TableRow, + rowType === 'subheader' && styles['TableRow-subheader'], selectable && condensed && styles.condensedRow, selected && styles['TableRow-selected'], subdued && styles['TableRow-subdued'], @@ -101,6 +131,8 @@ export const Row = memo(function Row({ if ((!disabled && selectable) || primaryLinkElement.current) { handleRowClick = (event: React.MouseEvent) => { + if (rowType === 'subheader') return; + if (!tableRowRef.current || isNavigating.current) { return; } @@ -140,14 +172,16 @@ export const Row = memo(function Row({ } const RowWrapper = condensed ? 'li' : 'tr'; - - const checkboxMarkup = selectable ? : null; + const checkboxMarkup = selectable ? ( + + ) : null; return ( ', () => { expect(row).toContainReactComponent(RowHoveredContext.Provider); }); + it('applies the styles.TableRow class to the table row element', () => { + const row = mountWithTable( + + + , + { + indexTableProps: { + itemCount: 50, + selectedItemsCount: 0, + onSelectionChange: onSelectionChangeSpy, + }, + }, + ); + + row.find('div', {className: 'Wrapper'})!.triggerKeypath('onClick', { + stopPropagation: noop, + key: ' ', + nativeEvent: {}, + }); + + expect(onSelectionChangeSpy).toHaveBeenCalledTimes(1); + expect(onSelectionChangeSpy).toHaveBeenCalledWith( + SelectionType.Range, + true, + range, + ); + }); + + it('does not call onSelectionChange with the `selectionRange` when present and the row is clicked', () => { + const onSelectionChangeSpy = jest.fn(); + const range: Range = [0, 1]; + const row = mountWithTable( + + + , + { + indexTableProps: { + itemCount: 50, + selectedItemsCount: 0, + onSelectionChange: onSelectionChangeSpy, + }, + }, + ); + + triggerOnClick(row, 1, defaultEvent); + + expect(onSelectionChangeSpy).not.toHaveBeenCalled(); + }); + }); + }); + + it('allows the checkbox to be indeterminate', () => { + const row = mountWithTable( + +
{ }; function getHeadingKey(heading: IndexTableHeading): string { - if ('id' in heading && heading.id) { + if (heading.id) { return heading.id; - } - - if (typeof heading.title === 'string') { + } else if (typeof heading.title === 'string') { return heading.title; } diff --git a/polaris-react/src/components/IndexTable/components/Cell/Cell.tsx b/polaris-react/src/components/IndexTable/components/Cell/Cell.tsx index 0ef0604da6d..b181e700fc7 100644 --- a/polaris-react/src/components/IndexTable/components/Cell/Cell.tsx +++ b/polaris-react/src/components/IndexTable/components/Cell/Cell.tsx @@ -5,21 +5,47 @@ import {classNames} from '../../../../utilities/css'; import styles from '../../IndexTable.scss'; export interface CellProps { + /** The table cell element to render. Render the cell as a `th` if it serves as a subheading + * @default 'td' + */ + as?: 'th' | 'td'; + /** The unique ID to set on the cell element */ + id?: string; + /** The cell contents */ children?: ReactNode; + /** Custom class name to apply to the cell element */ className?: string; + /** Whether the cell padding should be removed + * @default false + */ flush?: boolean; + /** For subheader cells -- The number of the columns that the cell element should extend to */ + colSpan?: HTMLTableCellElement['colSpan']; + /** For subheader cells -- Indicates the cells that the `th` element relates to */ + scope?: HTMLTableCellElement['scope']; + /** A space-separated list of the `th` cell IDs that describe or apply to it. Use for cells within a row that relate to a subheader cell in addition to their column header. */ + headers?: HTMLTableCellElement['headers']; } export const Cell = memo(function Cell({ children, - className, + className: customClassName, flush, + colSpan, + headers, + scope, + as = 'td', + id, }: CellProps) { - const cellClassName = classNames( - className, + const className = classNames( + customClassName, styles.TableCell, flush && styles['TableCell-flush'], ); - return {children} + , + ); + + expect(row.find(Row)?.find('tr')?.prop('className')).toContain( + styles.TableRow, + ); + }); + + describe('rowType', () => { + describe('when a `rowType` of `subheader` is set', () => { + it('applies the .TableRow-subheader class to the table row element', () => { + const row = mountWithTable( + + + , + ); + + expect(row.find(Row)?.find('tr')?.prop('className')).toContain( + styles['TableRow-subheader'], + ); + }); + + it('calls onSelectionChange with the `selectionRange` when present and the row checkbox cell is clicked', () => { + const onSelectionChangeSpy = jest.fn(); + const range: Range = [0, 1]; + const row = mountWithTable( + + + Child without data-primary-link + + Child without data-primary-link + + , + ); + + expect(row.find(Row)?.find(PolarisCheckbox)?.prop('checked')).toBe( + 'indeterminate', + ); + }); + it(`dispatches a mouse event when the row is clicked and selectMode is false`, () => { const spy = jest.fn(); const row = mountWithTable( diff --git a/polaris-react/src/components/Page/components/Header/Header.tsx b/polaris-react/src/components/Page/components/Header/Header.tsx index 3576b03419a..6636a9efbcf 100644 --- a/polaris-react/src/components/Page/components/Header/Header.tsx +++ b/polaris-react/src/components/Page/components/Header/Header.tsx @@ -26,6 +26,10 @@ import type {PaginationProps} from '../../../Pagination'; import {ActionMenu, hasGroupsWithActions} from '../../../ActionMenu'; import {isInterface} from '../../../../utilities/is-interface'; import {isReactElement} from '../../../../utilities/is-react-element'; +import { + IndexFiltersMode, + useSetIndexFiltersMode, +} from '../../../../utilities/index-filters'; import {Box} from '../../../Box'; import {InlineStack} from '../../../InlineStack'; import {FilterActionsProvider} from '../../../FilterActionsProvider'; @@ -88,6 +92,8 @@ export function Header({ }: HeaderProps) { const i18n = useI18n(); const {isNavigationCollapsed} = useMediaQuery(); + const {mode} = useSetIndexFiltersMode(); + const disableActions = mode !== IndexFiltersMode.Default; const isSingleRow = !primaryAction && @@ -108,7 +114,11 @@ export function Header({ pagination && !isNavigationCollapsed ? (
- +
) : null; @@ -125,7 +135,10 @@ export function Header({ ); const primaryActionMarkup = primaryAction ? ( - + ) : null; let actionMenuMarkup: MaybeJSX = null; @@ -135,8 +148,14 @@ export function Header({ ) { actionMenuMarkup = ( ({ + ...secondaryAction, + disabled: disableActions || secondaryAction.disabled, + }))} + groups={actionGroups.map((actionGroup) => ({ + ...actionGroup, + disabled: disableActions || actionGroup.disabled, + }))} rollup={isNavigationCollapsed} rollupActionsLabel={ title @@ -240,8 +259,10 @@ export function Header({ function PrimaryActionMarkup({ primaryAction, + disabled, }: { primaryAction: PrimaryAction | React.ReactNode; + disabled: boolean; }) { const {isNavigationCollapsed} = useMediaQuery(); @@ -253,6 +274,7 @@ function PrimaryActionMarkup({ shouldShowIconOnly(isNavigationCollapsed, primaryAction), { variant: primary ? 'primary' : undefined, + disabled: disabled || primaryAction.disabled, }, ); diff --git a/polaris-react/src/components/Page/components/Header/tests/Header.test.tsx b/polaris-react/src/components/Page/components/Header/tests/Header.test.tsx index 50e1caf137a..3b554982578 100644 --- a/polaris-react/src/components/Page/components/Header/tests/Header.test.tsx +++ b/polaris-react/src/components/Page/components/Header/tests/Header.test.tsx @@ -12,6 +12,7 @@ import {Tooltip} from '../../../../Tooltip'; import type {LinkAction, MenuActionDescriptor} from '../../../../../types'; import {Header} from '../Header'; import type {HeaderProps} from '../Header'; +import {IndexFiltersMode} from '../../../../../utilities/index-filters'; describe('
', () => { const mockProps: HeaderProps = { @@ -114,6 +115,26 @@ describe('
', () => { }); }); + it('renders a disabled button when a non-default IndexFiltersMode is set', () => { + const primaryAction: HeaderProps['primaryAction'] = { + content: buttonContent, + }; + + const header = mountWithApp( +
, + { + indexFilters: { + mode: IndexFiltersMode.Filtering, + }, + }, + ); + + expect(header).toContainReactComponent(Button, { + disabled: true, + children: buttonContent, + }); + }); + it('renders a `ReactNode`', () => { const PrimaryAction = () => null; @@ -152,6 +173,27 @@ describe('
', () => { hasNext: true, }); }); + + it('adds false values for hasNext and hasPrevious when a non-default IndexFiltersMode is set', () => { + const pagination = { + hasNext: true, + hasPrevious: true, + }; + + const header = mountWithApp( +
, + { + indexFilters: { + mode: IndexFiltersMode.Filtering, + }, + }, + ); + + expect(header).toContainReactComponent(Pagination, { + hasNext: false, + hasPrevious: false, + }); + }); }); describe('actionGroups', () => { @@ -179,6 +221,24 @@ describe('
', () => { groups: mockActionGroups, }); }); + + it('disables actions within the actionGroups when a non-default IndexFiltersMode is set', () => { + const wrapper = mountWithApp( +
, + { + indexFilters: { + mode: IndexFiltersMode.Filtering, + }, + }, + ); + + expect(wrapper).toContainReactComponent(ActionMenu, { + groups: mockActionGroups.map((actionGroup) => ({ + ...actionGroup, + disabled: true, + })), + }); + }); }); describe('action menu', () => { diff --git a/polaris-react/src/components/PolarisTestProvider/PolarisTestProvider.tsx b/polaris-react/src/components/PolarisTestProvider/PolarisTestProvider.tsx index f5e5e954823..124fd23f336 100644 --- a/polaris-react/src/components/PolarisTestProvider/PolarisTestProvider.tsx +++ b/polaris-react/src/components/PolarisTestProvider/PolarisTestProvider.tsx @@ -19,6 +19,11 @@ import type {LinkLikeComponent} from '../../utilities/link'; import {FeaturesContext} from '../../utilities/features'; import type {FeaturesConfig} from '../../utilities/features'; import {EphemeralPresenceManager} from '../EphemeralPresenceManager'; +import { + IndexFiltersMode, + IndexFiltersModeContext, +} from '../../utilities/index-filters'; +import type {IndexFiltersModeContextType} from '../../utilities/index-filters'; type FrameContextType = NonNullable>; type MediaQueryContextType = NonNullable< @@ -38,6 +43,8 @@ export interface WithPolarisTestProviderOptions { features?: FeaturesConfig; // Contexts provided by Frame frame?: Partial; + // Contexts provided by IndexFilters + indexFilters?: Partial; } export interface PolarisTestProviderProps @@ -50,6 +57,11 @@ const defaultMediaQuery: MediaQueryContextType = { isNavigationCollapsed: false, }; +const defaultIndexFilters: IndexFiltersModeContextType = { + mode: IndexFiltersMode.Default, + setMode: noop, +}; + export function PolarisTestProvider({ strict, children, @@ -58,6 +70,7 @@ export function PolarisTestProvider({ mediaQuery, features, frame, + indexFilters, }: PolarisTestProviderProps) { const Wrapper = strict ? StrictMode : Fragment; const intl = useMemo(() => new I18n(i18n || {}), [i18n]); @@ -69,6 +82,8 @@ export function PolarisTestProvider({ const mergedMediaQuery = merge(defaultMediaQuery, mediaQuery); + const mergedIndexFilters = merge(defaultIndexFilters, indexFilters); + return ( @@ -81,7 +96,11 @@ export function PolarisTestProvider({ - {children} + + {children} + diff --git a/polaris-react/src/components/index.ts b/polaris-react/src/components/index.ts deleted file mode 100644 index 1eb3969feed..00000000000 --- a/polaris-react/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {EphemeralPresenceManager} from './EphemeralPresenceManager'; diff --git a/polaris-react/src/index.ts b/polaris-react/src/index.ts index 1d94c278606..03204e44e47 100644 --- a/polaris-react/src/index.ts +++ b/polaris-react/src/index.ts @@ -183,18 +183,17 @@ export type {IconProps} from './components/Icon'; export {Image} from './components/Image'; export type {ImageProps} from './components/Image'; -export { - IndexFilters, - useSetIndexFiltersMode, - IndexFiltersMode, -} from './components/IndexFilters'; +export {IndexFilters} from './components/IndexFilters'; export type { IndexFiltersProps, SortButtonChoice, } from './components/IndexFilters'; export {IndexTable} from './components/IndexTable'; -export type {IndexTableProps} from './components/IndexTable'; +export type { + IndexTableProps, + RowProps as IndexTableRowProps, +} from './components/IndexTable'; export {Indicator} from './components/Indicator'; export type {IndicatorProps} from './components/Indicator'; @@ -420,6 +419,11 @@ export {WithinContentContext as _SECRET_INTERNAL_WITHIN_CONTENT_CONTEXT} from '. export {useEventListener} from './utilities/use-event-listener'; export {useTheme} from './utilities/use-theme'; export {useIndexResourceState} from './utilities/use-index-resource-state'; +export { + useSetIndexFiltersMode, + IndexFiltersMode, + IndexFiltersManager, +} from './utilities/index-filters'; export { useRowHovered as useIndexTableRowHovered, useRowSelected as useIndexTableRowSelected, diff --git a/polaris-react/src/utilities/index-filters/IndexFiltersManager.tsx b/polaris-react/src/utilities/index-filters/IndexFiltersManager.tsx new file mode 100644 index 00000000000..30969ffa9ff --- /dev/null +++ b/polaris-react/src/utilities/index-filters/IndexFiltersManager.tsx @@ -0,0 +1,29 @@ +import type {ContextType} from 'react'; +import React, {useMemo, useState} from 'react'; + +import {IndexFiltersModeContext} from './context'; +import {IndexFiltersMode} from './types'; + +export interface IndexFiltersManagerProps { + children?: React.ReactNode; +} + +type Context = NonNullable>; + +export function IndexFiltersManager({children}: IndexFiltersManagerProps) { + const [mode, setMode] = useState(IndexFiltersMode.Default); + + const value = useMemo( + () => ({ + mode, + setMode, + }), + [mode, setMode], + ); + + return ( + + {children} + + ); +} diff --git a/polaris-react/src/utilities/index-filters/context.tsx b/polaris-react/src/utilities/index-filters/context.tsx new file mode 100644 index 00000000000..84c975c3457 --- /dev/null +++ b/polaris-react/src/utilities/index-filters/context.tsx @@ -0,0 +1,12 @@ +import {createContext} from 'react'; + +import type {IndexFiltersMode} from './types'; + +export interface IndexFiltersModeContextType { + mode: IndexFiltersMode; + setMode: (mode: IndexFiltersMode) => void; +} + +export const IndexFiltersModeContext = createContext< + IndexFiltersModeContextType | undefined +>(undefined); diff --git a/polaris-react/src/utilities/index-filters/hooks.ts b/polaris-react/src/utilities/index-filters/hooks.ts new file mode 100644 index 00000000000..8ad2d5c74be --- /dev/null +++ b/polaris-react/src/utilities/index-filters/hooks.ts @@ -0,0 +1,30 @@ +import {useContext, useRef} from 'react'; + +import {IndexFiltersModeContext} from './context'; +import type {IndexFiltersMode} from './types'; + +export function useSetIndexFiltersMode(initialValue?: IndexFiltersMode) { + const indexFiltersMode = useContext(IndexFiltersModeContext); + + if (!indexFiltersMode) { + throw new Error( + 'No index filters manager was provided. Your application must be wrapped in an component. See https://polaris.shopify.com/components/app-provider for implementation instructions.', + ); + } + + const {mode, setMode} = indexFiltersMode; + + const hasMounted = useRef(false); + + if (!hasMounted.current) { + if (initialValue) { + setMode(initialValue); + } + hasMounted.current = true; + } + + return { + mode, + setMode, + }; +} diff --git a/polaris-react/src/utilities/index-filters/index.ts b/polaris-react/src/utilities/index-filters/index.ts new file mode 100644 index 00000000000..31b84d9d2b9 --- /dev/null +++ b/polaris-react/src/utilities/index-filters/index.ts @@ -0,0 +1,4 @@ +export * from './hooks'; +export * from './context'; +export * from './types'; +export * from './IndexFiltersManager'; diff --git a/polaris-react/src/utilities/index-filters/tests/IndexFiltersManager.test.tsx b/polaris-react/src/utilities/index-filters/tests/IndexFiltersManager.test.tsx new file mode 100644 index 00000000000..009d5fe31bc --- /dev/null +++ b/polaris-react/src/utilities/index-filters/tests/IndexFiltersManager.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import {mountWithApp} from 'tests/utilities'; + +import {IndexFiltersMode} from '../types'; +import {IndexFiltersManager} from '../IndexFiltersManager'; +import {useSetIndexFiltersMode} from '../hooks'; + +describe('', () => { + it('renders children with the context value', () => { + const ChildComponent = () => { + const {mode, setMode} = useSetIndexFiltersMode(); + return ( +
+ {mode} + + + +
+ ); + }; + + const wrapper = mountWithApp( + + + , + ); + + const modeElement = wrapper.find('span', { + id: 'mode', + }); + const defaultButton = wrapper.find('button', { + id: 'default', + }); + const filteringButton = wrapper.find('button', { + id: 'filtering', + }); + const editingButton = wrapper.find('button', { + id: 'editing-columns', + }); + expect(modeElement?.text()).toBe(IndexFiltersMode.Default); + + wrapper.act(() => { + filteringButton?.trigger('onClick'); + }); + + expect(modeElement?.text()).toBe(IndexFiltersMode.Filtering); + + wrapper.act(() => { + editingButton?.trigger('onClick'); + }); + + expect(modeElement?.text()).toBe(IndexFiltersMode.EditingColumns); + + wrapper.act(() => { + defaultButton?.trigger('onClick'); + }); + + expect(modeElement?.text()).toBe(IndexFiltersMode.Default); + }); +}); diff --git a/polaris-react/src/utilities/index-filters/tests/hooks.test.tsx b/polaris-react/src/utilities/index-filters/tests/hooks.test.tsx new file mode 100644 index 00000000000..02508f021ff --- /dev/null +++ b/polaris-react/src/utilities/index-filters/tests/hooks.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {mountWithApp} from 'tests/utilities'; + +import * as hooks from '../hooks'; +import {IndexFiltersMode} from '../types'; + +describe('useSetIndexFiltersMode', () => { + it('returns mode from the provider', () => { + const spy = jest.fn(); + + function MockComponent() { + const value = hooks.useSetIndexFiltersMode(); + spy(value); + return null; + } + + mountWithApp(, { + indexFilters: { + mode: IndexFiltersMode.Filtering, + }, + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + mode: IndexFiltersMode.Filtering, + }), + ); + }); + + describe('with a mocked value', () => { + it('calls setMode with defaultMode inside useEffect', () => { + const initialMode = IndexFiltersMode.EditingColumns; + const useSetIndexFiltersModeMock = jest.spyOn( + hooks, + 'useSetIndexFiltersMode', + ); + + function MockComponent() { + hooks.useSetIndexFiltersMode(initialMode); + return null; + } + mountWithApp(); + + expect(useSetIndexFiltersModeMock).toHaveBeenCalledTimes(1); + expect(useSetIndexFiltersModeMock).toHaveBeenCalledWith(initialMode); + }); + }); +}); diff --git a/polaris-react/src/utilities/index-filters/types.ts b/polaris-react/src/utilities/index-filters/types.ts new file mode 100644 index 00000000000..9cde25ffa08 --- /dev/null +++ b/polaris-react/src/utilities/index-filters/types.ts @@ -0,0 +1,5 @@ +export enum IndexFiltersMode { + Default = 'DEFAULT', + Filtering = 'FILTERING', + EditingColumns = 'EDITING_COLUMNS', +} diff --git a/polaris-react/src/utilities/index-provider/hooks.ts b/polaris-react/src/utilities/index-provider/hooks.ts index 5ae3d9d0f01..90d546b25ea 100644 --- a/polaris-react/src/utilities/index-provider/hooks.ts +++ b/polaris-react/src/utilities/index-provider/hooks.ts @@ -179,6 +179,8 @@ export function useHandleBulkSelection({ selectionType === SelectionType.All ) { onSelectionChange(selectionType, toggleType); + } else if (selectionType === SelectionType.Range) { + onSelectionChange(SelectionType.Range, toggleType, selection); } }, [onSelectionChange], diff --git a/polaris-react/src/utilities/index-provider/tests/hooks.test.tsx b/polaris-react/src/utilities/index-provider/tests/hooks.test.tsx index de56b51a30c..d7958a0c190 100644 --- a/polaris-react/src/utilities/index-provider/tests/hooks.test.tsx +++ b/polaris-react/src/utilities/index-provider/tests/hooks.test.tsx @@ -215,7 +215,7 @@ describe('useHandleBulkSelection', () => { return ; } - it('selects ranges', () => { + it('selects ranges with shift key selection', () => { const onSelectionChangeSpy = jest.fn(); const mockComponent = mount( , @@ -246,4 +246,21 @@ describe('useHandleBulkSelection', () => { [3, 4], ); }); + + it('selects ranges with subheader selection', () => { + const onSelectionChangeSpy = jest.fn(); + const mockComponent = mount( + , + ); + + const typedChild = mockComponent.find(TypedChild)!; + + typedChild.trigger('onSelectionChange', SelectionType.Range, true, [1, 3]); + + expect(onSelectionChangeSpy).toHaveBeenLastCalledWith( + SelectionType.Range, + true, + [1, 3], + ); + }); }); diff --git a/polaris-react/src/utilities/index-provider/types.ts b/polaris-react/src/utilities/index-provider/types.ts index 43a9087cb46..093d847ed51 100644 --- a/polaris-react/src/utilities/index-provider/types.ts +++ b/polaris-react/src/utilities/index-provider/types.ts @@ -7,6 +7,7 @@ export enum SelectionType { Page = 'page', Multi = 'multi', Single = 'single', + Range = 'range', } export type Range = [number, number]; @@ -27,6 +28,7 @@ export interface IndexProviderProps { selectionType: SelectionType, toggleType: boolean, selection?: string | Range, + position?: number, ): void; } diff --git a/polaris-react/src/utilities/index-table/context.ts b/polaris-react/src/utilities/index-table/context.ts index 425ae65f874..0bb4515dd1e 100644 --- a/polaris-react/src/utilities/index-table/context.ts +++ b/polaris-react/src/utilities/index-table/context.ts @@ -2,7 +2,7 @@ import {createContext} from 'react'; interface RowContextType { itemId?: string; - selected?: boolean; + selected?: boolean | 'indeterminate'; disabled?: boolean; position?: number; onInteraction?: (event: React.MouseEvent | React.KeyboardEvent) => void; diff --git a/polaris-react/src/utilities/tests/use-index-resource-state.test.tsx b/polaris-react/src/utilities/tests/use-index-resource-state.test.tsx index ccbc04c489c..fd25d7847c1 100644 --- a/polaris-react/src/utilities/tests/use-index-resource-state.test.tsx +++ b/polaris-react/src/utilities/tests/use-index-resource-state.test.tsx @@ -496,6 +496,92 @@ describe('useIndexResourceState', () => { }); }); }); + + describe('SelectionType.Range', () => { + describe('with a custom resource filter', () => { + it('only selects resources that match the filter', () => { + const idOne = '1'; + const idTwo = '2'; + const idThree = '3'; + const resources = [{id: idOne}, {id: idTwo}, {id: idThree}]; + const customResoureFilter = (item: typeof resources[0]) => { + return item.id !== idOne; + }; + const mockComponent = mountWithApp( + , + ); + + mockComponent + .find(TypedChild)! + .trigger('onClick', SelectionType.Range, true, [0, 2]); + + expect(mockComponent).toContainReactComponent(TypedChild, { + selectedResources: [idTwo, idThree], + }); + }); + }); + + it('selects all resources within range when none are selected', () => { + const idOne = '1'; + const idTwo = '2'; + const idThree = '3'; + const resources = [{id: idOne}, {id: idTwo}, {id: idThree}]; + const mockComponent = mountWithApp( + , + ); + + mockComponent + .find(TypedChild)! + .trigger('onClick', SelectionType.Range, true, [0, 2]); + + expect(mockComponent).toContainReactComponent(TypedChild, { + selectedResources: [idOne, idTwo, idThree], + }); + }); + + it('selects all resources within range when some are selected', () => { + const idOne = '1'; + const idTwo = '2'; + const idThree = '3'; + const resources = [{id: idOne}, {id: idTwo}, {id: idThree}]; + const options = {selectedResources: [idOne]}; + const mockComponent = mountWithApp( + , + ); + + mockComponent + .find(TypedChild)! + .trigger('onClick', SelectionType.Range, true, [0, 2]); + + expect(mockComponent).toContainReactComponent(TypedChild, { + selectedResources: [idOne, idTwo, idThree], + }); + }); + + it('deselects all resources within range when all are selected', () => { + const idOne = '1'; + const idTwo = '2'; + const idThree = '3'; + const resources = [{id: idOne}, {id: idTwo}, {id: idThree}]; + const mockComponent = mountWithApp( + , + ); + + mockComponent + .find(TypedChild)! + .trigger('onClick', SelectionType.Range, false, [0, 2]); + + expect(mockComponent).toContainReactComponent(TypedChild, { + selectedResources: [], + }); + }); + }); }); describe('clearSelection', () => { diff --git a/polaris-react/src/utilities/use-index-resource-state.ts b/polaris-react/src/utilities/use-index-resource-state.ts index 361fbffec30..2c3eb1463b6 100644 --- a/polaris-react/src/utilities/use-index-resource-state.ts +++ b/polaris-react/src/utilities/use-index-resource-state.ts @@ -5,6 +5,7 @@ export enum SelectionType { Page = 'page', Multi = 'multi', Single = 'single', + Range = 'range', } type Range = [number, number]; type ResourceIDResolver = ( @@ -32,7 +33,7 @@ export function useIndexResourceState( selectedResources?: string[]; allResourcesSelected?: boolean; resourceIDResolver?: ResourceIDResolver; - resourceFilter?: (value: T) => boolean; + resourceFilter?: (value: T, index: number) => boolean; } = { selectedResources: [], allResourcesSelected: false, @@ -85,18 +86,22 @@ export function useIndexResourceState( break; case SelectionType.Multi: if (!selection) break; - setSelectedResources((newSelectedResources) => { + setSelectedResources((currentSelectedResources) => { const ids: string[] = []; const filteredResources = resourceFilter ? resources.filter(resourceFilter) : resources; - for (let i = selection[0] as number; i <= selection[1]; i++) { + for ( + let i = selection[0] as number; + i <= (selection[1] as number); + i++ + ) { if (filteredResources.includes(resources[i])) { const id = resourceIDResolver(resources[i]); if ( - (isSelecting && !newSelectedResources.includes(id)) || - (!isSelecting && newSelectedResources.includes(id)) + (isSelecting && !currentSelectedResources.includes(id)) || + (!isSelecting && currentSelectedResources.includes(id)) ) { ids.push(id); } @@ -104,8 +109,49 @@ export function useIndexResourceState( } return isSelecting - ? [...newSelectedResources, ...ids] - : newSelectedResources.filter((id) => !ids.includes(id)); + ? [...currentSelectedResources, ...ids] + : currentSelectedResources.filter((id) => !ids.includes(id)); + }); + + break; + case SelectionType.Range: + if (!selection) break; + + setSelectedResources((currentSelectedResources) => { + const filteredResources = resourceFilter + ? resources.filter(resourceFilter) + : resources; + + const resourceIds = filteredResources.map(resourceIDResolver); + + const selectedIds = resourceIds.slice( + Number(selection[0]), + Number(selection[1]) + 1, + ); + + const isIndeterminate = selectedIds.some((id) => { + return selectedResources.includes(id); + }); + + const isChecked = selectedIds.every((id) => { + return selectedResources.includes(id); + }); + + const isSelectingAllInRange = + !isChecked && (isSelecting || isIndeterminate); + + const nextSelectedResources = isSelectingAllInRange + ? [ + ...new Set([ + ...currentSelectedResources, + ...selectedIds, + ]).values(), + ] + : currentSelectedResources.filter( + (id) => !selectedIds.includes(id), + ); + + return nextSelectedResources; }); break; } @@ -113,7 +159,7 @@ export function useIndexResourceState( [ allResourcesSelected, resourceFilter, - selectedResources.length, + selectedResources, resources, resourceIDResolver, ], diff --git a/polaris-tokens/src/themes/base/border.ts b/polaris-tokens/src/themes/base/border.ts index ee0f08a42d8..bc98c9590e5 100644 --- a/polaris-tokens/src/themes/base/border.ts +++ b/polaris-tokens/src/themes/base/border.ts @@ -1,6 +1,7 @@ import type {Experimental} from '../../types'; import type {MetaTokenProperties} from '../types'; import {size} from '../../size'; +import {createVar as createVarName} from '../../utilities'; type BorderRadiusScaleExperimental = Experimental<'0' | '1_5'>; @@ -140,3 +141,7 @@ export const border: { value: '1px', }, }; + +export function createVar(borderTokenName: BorderTokenName) { + return `var(${createVarName(borderTokenName)})`; +} diff --git a/polaris-tokens/src/themes/base/font.ts b/polaris-tokens/src/themes/base/font.ts index 50b52166d00..db9c9e7ca67 100644 --- a/polaris-tokens/src/themes/base/font.ts +++ b/polaris-tokens/src/themes/base/font.ts @@ -1,6 +1,7 @@ import {size} from '../../size'; import type {Experimental} from '../../types'; import type {MetaTokenProperties} from '../types'; +import {createVar as createVarName} from '../../utilities'; type FontFamilyAlias = 'sans' | 'mono'; @@ -175,3 +176,7 @@ export const font: { value: '48px', }, }; + +export function createVar(fontTokenName: FontTokenName) { + return `var(${createVarName(fontTokenName)})`; +} diff --git a/polaris-tokens/src/themes/light-uplift.ts b/polaris-tokens/src/themes/light-uplift.ts new file mode 100644 index 00000000000..ea7122aea2e --- /dev/null +++ b/polaris-tokens/src/themes/light-uplift.ts @@ -0,0 +1,323 @@ +import * as colors from '../colors-experimental'; +import {size} from '../size'; + +import {createMetaTheme, createMetaThemePartial} from './utils'; + +export const metaThemeLightUpliftPartial = createMetaThemePartial({ + border: { + // v12.0.0 + 'border-radius-0': {value: size[0]}, + 'border-radius-050': {value: size['050']}, + 'border-radius-100': {value: size[100]}, + 'border-radius-150': {value: size[150]}, + 'border-radius-200': {value: size[200]}, + 'border-radius-300': {value: size[300]}, + 'border-radius-400': {value: size[400]}, + 'border-radius-500': {value: size[500]}, + 'border-radius-750': {value: size[750]}, + 'border-width-0165': {value: size['0165']}, + 'border-width-025': {value: size['025']}, + 'border-width-050': {value: size['050']}, + 'border-width-100': {value: size[100]}, + }, + color: { + // v12.0.0 + 'color-bg-surface': {value: colors.gray[1]}, + 'color-bg-surface-secondary': {value: colors.gray[4]}, + 'color-bg-surface-secondary-hover': {value: colors.gray[6]}, + 'color-bg-surface-secondary-active': {value: colors.gray[7]}, + 'color-bg-surface-tertiary': {value: colors.gray[5]}, + 'color-bg-fill-tertiary': {value: colors.gray[8]}, + 'color-bg-fill-tertiary-hover': {value: colors.gray[9]}, + 'color-bg-fill-tertiary-active': {value: colors.gray[10]}, + 'color-input-bg-surface': {value: colors.gray[2]}, + 'color-input-bg-surface-hover': {value: colors.gray[3]}, + 'color-input-bg-surface-active': {value: colors.gray[4]}, + 'color-bg-surface-selected': {value: colors.gray[3]}, + 'color-bg-fill-success': {value: colors.green[12]}, + 'color-bg-fill-success-hover': {value: colors.green[13]}, + 'color-bg-fill-success-active': {value: colors.green[14]}, + 'color-bg-fill-success-secondary': {value: colors.green[3]}, + 'color-bg-surface-success': {value: colors.green[3]}, + 'color-bg-surface-success-hover': {value: colors.green[4]}, + 'color-bg-surface-success-active': {value: colors.green[5]}, + 'color-bg-fill-critical': {value: colors.red[12]}, + 'color-bg-fill-critical-hover': {value: colors.red[13]}, + 'color-bg-fill-critical-active': {value: colors.red[14]}, + 'color-bg-fill-critical-secondary': {value: colors.red[6]}, + 'color-bg-surface-critical': {value: colors.red[4]}, + 'color-bg-surface-critical-hover': {value: colors.red[5]}, + 'color-bg-surface-critical-active': {value: colors.red[6]}, + 'color-bg-fill-caution': {value: colors.yellow[6]}, + 'color-bg-fill-caution-secondary': {value: colors.yellow[4]}, + 'color-bg-surface-caution': {value: colors.yellow[2]}, + 'color-bg-surface-caution-hover': {value: colors.yellow[3]}, + 'color-bg-surface-caution-active': {value: colors.yellow[4]}, + 'color-bg-fill-info': {value: colors.azure[9]}, + 'color-bg-fill-info-secondary': {value: colors.azure[4]}, + 'color-bg-surface-info': {value: colors.azure[3]}, + 'color-bg-surface-info-hover': {value: colors.azure[4]}, + 'color-bg-surface-info-active': {value: colors.azure[6]}, + 'color-bg-fill-warning': {value: colors.orange[9]}, + 'color-bg-fill-warning-secondary': {value: colors.orange[7]}, + 'color-bg-surface-warning': {value: colors.orange[3]}, + 'color-bg-fill-magic': {value: colors.purple[12]}, + 'color-bg-fill-magic-secondary': {value: colors.purple[6]}, + 'color-bg-fill-magic-secondary-hover': {value: colors.purple[7]}, + 'color-bg-fill-magic-secondary-active': {value: colors.purple[8]}, + 'color-bg-surface-magic-hover': {value: colors.purple[4]}, + 'color-bg-fill-secondary': {value: colors.gray[6]}, + 'color-bg-fill-inverse': {value: colors.gray[15]}, + 'color-bg-fill-inverse-hover': {value: colors.gray[14]}, + 'color-bg-fill-inverse-active': {value: colors.gray[13]}, + 'color-bg-surface-transparent': {value: colors.blackAlpha[1]}, + 'color-bg-fill-transparent-hover': {value: colors.blackAlpha[5]}, + 'color-bg-fill-transparent-active': {value: colors.blackAlpha[6]}, + 'color-bg-fill-disabled': {value: colors.blackAlpha[5]}, + 'color-bg-fill-transparent-secondary': {value: colors.blackAlpha[6]}, + 'color-bg-fill-brand-disabled': {value: colors.blackAlpha[9]}, + 'color-backdrop-bg': {value: colors.blackAlpha[14]}, + 'color-avatar-bg-fill': {value: colors.gray[11]}, + 'color-avatar-one-bg-fill': {value: colors.magenta[7]}, + 'color-avatar-two-bg-fill': {value: colors.green[7]}, + 'color-avatar-three-bg-fill': {value: colors.cyan[7]}, + 'color-avatar-four-bg-fill': {value: colors.azure[7]}, + 'color-avatar-five-bg-fill': {value: colors.rose[7]}, + 'color-text-secondary': {value: colors.gray[13]}, + 'color-text-emphasis': {value: colors.blue[13]}, + 'color-text-emphasis-hover': {value: colors.blue[14]}, + 'color-text-emphasis-active': {value: colors.blue[15]}, + 'color-text-brand': {value: colors.gray[14]}, + 'color-text-brand-hover': {value: colors.gray[15]}, + 'color-text-info-on-bg-fill': {value: colors.azure[16]}, + 'color-text-inverse-secondary': {value: colors.gray[11]}, + 'color-avatar-text-on-bg-fill': {value: colors.gray[1]}, + 'color-avatar-one-text-on-bg-fill': {value: colors.magenta[14]}, + 'color-avatar-two-text-on-bg-fill': {value: colors.green[14]}, + 'color-avatar-three-text-on-bg-fill': {value: colors.cyan[14]}, + 'color-avatar-four-text-on-bg-fill': {value: colors.azure[14]}, + 'color-avatar-five-text-on-bg-fill': {value: colors.rose[14]}, + 'color-icon-secondary': {value: colors.gray[12]}, + 'color-icon-emphasis': {value: colors.blue[13]}, + 'color-icon-emphasis-hover': {value: colors.blue[14]}, + 'color-icon-emphasis-active': {value: colors.blue[15]}, + 'color-icon-brand': {value: colors.gray[16]}, + 'color-border-secondary': {value: colors.gray[7]}, + 'color-input-border': {value: colors.gray[12]}, + 'color-input-border-hover': {value: colors.gray[13]}, + 'color-input-border-active': {value: colors.gray[16]}, + 'color-border-emphasis-hover': {value: colors.blue[14]}, + 'color-border-emphasis-active': {value: colors.blue[15]}, + 'color-border-focus': {value: colors.blue[13]}, + 'color-border-brand': {value: colors.gray[8]}, + 'color-border-critical-secondary': {value: colors.red[14]}, + 'color-border-magic-secondary': {value: colors.purple[12]}, + // v11.0.0 + 'color-bg-inverse': {value: colors.gray[16]}, + 'color-bg-inset-strong': {value: colors.gray[15]}, + 'color-bg-inverse-hover': {value: colors.gray[14]}, + 'color-bg-inverse-active': {value: colors.gray[13]}, + 'color-bg-strong-hover': {value: colors.gray[9]}, + 'color-bg-strong-active': {value: colors.gray[10]}, + 'color-bg-strong': {value: colors.gray[8]}, + 'color-bg-subdued-active': {value: colors.gray[7]}, + 'color-bg-disabled': {value: colors.gray[7]}, + 'color-bg-interactive-disabled': {value: colors.gray[7]}, + 'color-bg-app': {value: colors.gray[6]}, + 'color-bg-app-hover': {value: colors.gray[2]}, + 'color-bg-app-selected': {value: colors.gray[3]}, + 'color-bg-active': {value: colors.gray[4]}, + 'color-bg-subdued-hover': {value: colors.gray[6]}, + 'color-bg-inset': {value: colors.gray[6]}, + 'color-bg-hover': {value: colors.gray[3]}, + 'color-bg-subdued': {value: colors.gray[4]}, + 'color-bg-input': {value: colors.gray[1]}, + 'color-bg': {value: colors.gray[1]}, + 'color-bg-primary-active': {value: colors.gray[16]}, + 'color-bg-primary-hover': {value: colors.gray[16]}, + 'color-bg-primary': {value: colors.gray[15]}, + 'color-bg-success-strong': {value: colors.green[12]}, + 'color-bg-success': {value: colors.green[3]}, + 'color-bg-primary-subdued-active': {value: colors.gray[6]}, + 'color-bg-success-subdued': {value: colors.green[3]}, + 'color-bg-primary-subdued-hover': {value: colors.gray[7]}, + 'color-bg-success-subdued-hover': {value: colors.green[5]}, + 'color-bg-primary-subdued': {value: colors.gray[8]}, + 'color-bg-primary-subdued-selected': {value: colors.gray[6]}, + 'color-bg-critical-strong-active': {value: colors.red[14]}, + 'color-bg-critical-strong-hover': {value: colors.red[13]}, + 'color-bg-critical-strong': {value: colors.red[12]}, + 'color-bg-critical-subdued-active': {value: colors.red[6]}, + 'color-bg-critical': {value: colors.red[6]}, + 'color-bg-critical-subdued': {value: colors.red[4]}, + 'color-bg-critical-subdued-hover': {value: colors.red[5]}, + 'color-bg-caution-strong': {value: colors.yellow[6]}, + 'color-bg-caution': {value: colors.yellow[4]}, + 'color-bg-caution-subdued-active': {value: colors.yellow[4]}, + 'color-bg-caution-subdued': {value: colors.yellow[2]}, + 'color-bg-caution-subdued-hover': {value: colors.yellow[3]}, + 'color-bg-info-strong': {value: colors.azure[9]}, + 'color-bg-info-subdued-active': {value: colors.azure[6]}, + 'color-bg-info': {value: colors.azure[4]}, + 'color-bg-info-subdued': {value: colors.azure[3]}, + 'color-bg-info-subdued-hover': {value: colors.azure[4]}, + 'color-bg-interactive-active': {value: colors.gray[14]}, + 'color-bg-interactive-hover': {value: colors.gray[15]}, + 'color-bg-interactive': {value: colors.gray[16]}, + 'color-bg-interactive-subdued-active': {value: colors.gray[6]}, + 'color-bg-interactive-subdued-hover': {value: colors.gray[7]}, + 'color-bg-interactive-subdued': {value: colors.gray[8]}, + 'color-bg-interactive-selected': {value: colors.gray[6]}, + 'color-bg-warning': {value: colors.orange[7]}, + 'color-bg-magic-strong': {value: colors.purple[12]}, + 'color-bg-magic-hover': {value: colors.purple[7]}, + 'color-bg-magic-active': {value: colors.purple[8]}, + 'color-bg-magic': {value: colors.purple[6]}, + 'color-bg-magic-subdued-hover': {value: colors.purple[4]}, + 'color-bg-magic-subdued-active': {value: colors.purple[6]}, + 'color-bg-magic-subdued': {value: colors.purple[3]}, + 'color-border-input-hover': {value: colors.gray[13]}, + 'color-border-inverse': {value: colors.gray[13]}, + 'color-border-strong-hover': {value: colors.gray[11]}, + 'color-border-input': {value: colors.gray[12]}, + 'color-border-hover': {value: colors.gray[10]}, + 'color-border-strong': {value: colors.gray[10]}, + 'color-border': {value: colors.gray[8]}, + 'color-border-disabled': {value: colors.gray[7]}, + 'color-border-subdued': {value: colors.gray[7]}, + 'color-border-interactive-disabled': {value: colors.gray[7]}, + 'color-border-primary': {value: colors.gray[8]}, + 'color-border-success': {value: colors.green[5]}, + 'color-border-success-subdued': {value: colors.green[5]}, + 'color-border-critical-active': {value: colors.red[11]}, + 'color-border-critical-hover': {value: colors.red[10]}, + 'color-border-critical': {value: colors.red[9]}, + 'color-border-critical-subdued': {value: colors.red[9]}, + 'color-border-caution': {value: colors.yellow[5]}, + 'color-border-caution-subdued': {value: colors.yellow[5]}, + 'color-border-info': {value: colors.azure[9]}, + 'color-border-info-subdued': {value: colors.azure[9]}, + 'color-border-interactive-active': {value: colors.blue[15]}, + 'color-border-interactive-hover': {value: colors.blue[14]}, + 'color-border-interactive': {value: colors.blue[13]}, + 'color-border-interactive-focus': {value: colors.blue[13]}, + 'color-border-interactive-subdued': {value: colors.blue[13]}, + 'color-border-magic-strong': {value: colors.purple[12]}, + 'color-border-magic': {value: colors.purple[10]}, + 'color-icon-hover': {value: colors.gray[15]}, + 'color-icon': {value: colors.gray[14]}, + 'color-icon-subdued': {value: colors.gray[12]}, + 'color-icon-disabled': {value: colors.gray[10]}, + 'color-icon-interactive-disabled': {value: colors.gray[10]}, + 'color-icon-inverse': {value: colors.gray[8]}, + 'color-icon-on-color': {value: colors.gray[1]}, + 'color-icon-primary': {value: colors.gray[16]}, + 'color-icon-success': {value: colors.green[12]}, + 'color-icon-critical': {value: colors.red[11]}, + 'color-icon-caution': {value: colors.yellow[11]}, + 'color-icon-info': {value: colors.azure[11]}, + 'color-icon-warning': {value: colors.orange[11]}, + 'color-icon-interactive-active': {value: colors.blue[15]}, + 'color-icon-interactive-hover': {value: colors.blue[14]}, + 'color-icon-interactive': {value: colors.blue[13]}, + 'color-icon-interactive-inverse': {value: colors.blue[8]}, + 'color-icon-magic': {value: colors.purple[13]}, + 'color-text': {value: colors.gray[15]}, + 'color-text-subdued': {value: colors.gray[13]}, + 'color-text-disabled': {value: colors.gray[11]}, + 'color-text-interactive-disabled': {value: colors.gray[10]}, + 'color-text-inverse-subdued': {value: colors.gray[10]}, + 'color-text-inverse': {value: colors.gray[8]}, + 'color-text-on-color': {value: colors.gray[1]}, + 'color-text-success-strong': {value: colors.green[15]}, + 'color-text-success': {value: colors.green[15]}, + 'color-text-primary': {value: colors.gray[14]}, + 'color-text-primary-hover': {value: colors.gray[14]}, + 'color-text-critical-strong': {value: colors.red[14]}, + 'color-text-critical-active': {value: colors.red[16]}, + 'color-text-critical': {value: colors.red[14]}, + 'color-text-caution-strong': {value: colors.yellow[15]}, + 'color-text-caution': {value: colors.yellow[15]}, + 'color-text-info-strong': {value: colors.azure[16]}, + 'color-text-info': {value: colors.azure[14]}, + 'color-text-warning-strong': {value: colors.orange[16]}, + 'color-text-interactive-active': {value: colors.blue[15]}, + 'color-text-interactive-hover': {value: colors.blue[14]}, + 'color-text-interactive': {value: colors.blue[13]}, + 'color-text-interactive-inverse': {value: colors.blue[8]}, + 'color-text-magic-strong': {value: colors.purple[15]}, + 'color-text-magic': {value: colors.purple[14]}, + }, + font: { + // v12.0.0 + 'font-size-275': {value: size[275]}, + 'font-size-325': {value: size[325]}, + 'font-size-350': {value: size[350]}, + 'font-size-750': {value: size[750]}, + 'font-size-900': {value: size[900]}, + 'font-size-1000': {value: size[1000]}, + 'font-line-height-300': {value: size[300]}, + 'font-line-height-400': {value: size[400]}, + 'font-line-height-500': {value: size[500]}, + 'font-line-height-600': {value: size[600]}, + 'font-line-height-700': {value: size[700]}, + 'font-line-height-800': {value: size[800]}, + 'font-line-height-1000': {value: size[1000]}, + 'font-line-height-1200': {value: size[1200]}, + // v11.0.0 + 'font-family-sans': { + value: + "'Inter', -apple-system, BlinkMacSystemFont, 'San Francisco', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif", + }, + 'font-size-500': {value: '30px'}, + 'font-size-600': {value: '36px'}, + }, + motion: { + // v11.0.0 + 'motion-ease-out': {value: 'cubic-bezier(0.19, 0.91, 0.38, 1)'}, + }, + space: { + // v12.0.0 + 'space-050': {value: size['050']}, + 'space-100': {value: size[100]}, + 'space-150': {value: size[150]}, + 'space-200': {value: size[200]}, + 'space-300': {value: size[300]}, + 'space-400': {value: size[400]}, + 'space-500': {value: size[500]}, + 'space-600': {value: size[600]}, + 'space-800': {value: size[800]}, + 'space-1000': {value: size[1000]}, + 'space-1200': {value: size[1200]}, + 'space-1600': {value: size[1600]}, + 'space-2000': {value: size[2000]}, + 'space-2400': {value: size[2400]}, + 'space-2800': {value: size[2800]}, + 'space-3200': {value: size[3200]}, + }, + shadow: { + // v11.0.0 + 'shadow-inset-lg': { + value: + 'inset -1px 0px 1px rgba(0, 0, 0, 0.2), inset 1px 0px 1px rgba(0, 0, 0, 0.2), inset 0px 2px 1px rgba(0, 0, 0, 0.6)', + }, + 'shadow-inset-md': { + value: + 'inset -1px 0px 1px rgba(0, 0, 0, 0.12), inset 1px 0px 1px rgba(0, 0, 0, 0.12), inset 0px 2px 1px rgba(0, 0, 0, 0.2)', + }, + 'shadow-inset-sm': { + value: + 'inset 0px 1px 1px rgba(0, 0, 0, 0.15), inset 0px 1px 2px rgba(255, 255, 255, 0.15)', + }, + 'shadow-xs': {value: '0px 1px 0px rgba(0, 0, 0, 0.07)'}, + 'shadow-sm': {value: '0px 3px 1px -1px rgba(0, 0, 0, 0.07)'}, + 'shadow-md': {value: '0px 4px 6px -2px rgba(0, 0, 0, 0.2)'}, + 'shadow-lg': {value: '0px 8px 16px -4px rgba(0, 0, 0, 0.22)'}, + 'shadow-xl': {value: '0px 12px 20px -8px rgba(0, 0, 0, 0.24)'}, + 'shadow-2xl': {value: '0px 20px 20px -8px rgba(0, 0, 0, 0.28)'}, + }, +}); + +export const metaThemeLightUplift = createMetaTheme( + metaThemeLightUpliftPartial, +); diff --git a/polaris.shopify.com/content/coming-soon/index.md b/polaris.shopify.com/content/coming-soon/index.md new file mode 100644 index 00000000000..29d1a19e890 --- /dev/null +++ b/polaris.shopify.com/content/coming-soon/index.md @@ -0,0 +1,5 @@ +--- +title: Coming soon +hideChildren: true +url: /coming-soon/view-transitions +--- diff --git a/polaris.shopify.com/content/coming-soon/view-transitions.md b/polaris.shopify.com/content/coming-soon/view-transitions.md new file mode 100644 index 00000000000..e4cf324cbc4 --- /dev/null +++ b/polaris.shopify.com/content/coming-soon/view-transitions.md @@ -0,0 +1,27 @@ +--- +title: Embracing the future with the View Transitions API +description: Merchants need tools that are not just powerful, but also intuitive. That's why we're experimenting with new ways to enhance the Shopify admin experience using the View Transitions API. +keywords: + - motion + - animations + - transitions + - view transitions + - view transitions api +--- + +## Tl;dr: + +The View Transitions API is an experimental browser API supported in Google Chrome. It streamlines the visual transitions between different pages or elements using a combination of CSS pseudo elements, JavaScript, and screenshots of the old and new DOM state. By reducing the need for complex custom JavaScript and CSS, this API provides a new opportunity to elevate the Shopify admin experience. + +## Impact potential + +For merchants, the impacts of the View Transitions API will be experienced through: + +- **Perceived performance:** View transitions used to animate navigation or state changes in conjunction with prefetching, lazy loading, and caching will make the Shopify admin feel faster and more responsive. +- **Enhanced accessibility:** Built in features for managing focus and other accessibility concerns during a view transition will make taking action or changing contexts a better experience for all merchants. +- **Reduced cognitive load:** Page transitions between list and detail views will make clearer connections between the levels of detail in commerce objects like products. + +For admin builders, systemizing the View Transitions API with shared patterns will support: + +- **Streamlined development:** Systemized ways to use and share transitions will make it easier for admin builders to create and maintain high quality animations. +- **Domain specific solutions:** The flexibility of customizing view transitions using their pseudo elements opens up possibilities for systemizing crafted, engaging ways to introduce and reinforce important commerce concepts shared across the Shopify admin that will make it a more enjoyable tool for merchants to use. diff --git a/polaris.shopify.com/content/components/lists/resource-list.md b/polaris.shopify.com/content/components/lists/resource-list.md index 71f81f47a02..a687495429b 100644 --- a/polaris.shopify.com/content/components/lists/resource-list.md +++ b/polaris.shopify.com/content/components/lists/resource-list.md @@ -193,5 +193,5 @@ Resource lists should: ## Related components -- To present structured data for comparison and analysis, like when helping merchants to gain insights or review analytics, use the [data table component](https://polaris.shopify.com/components/data-table) +- To present structured data for comparison and analysis, like when helping merchants to gain insights or review analytics, use the [data table component](https://polaris.shopify.com/components/tables/data-table) - To display a simple list of related content, [use the list component](https://polaris.shopify.com/components/lists/list) diff --git a/polaris.shopify.com/content/components/tables/index-table.md b/polaris.shopify.com/content/components/tables/index-table.md index 0df05c3767a..4947756af2e 100644 --- a/polaris.shopify.com/content/components/tables/index-table.md +++ b/polaris.shopify.com/content/components/tables/index-table.md @@ -69,6 +69,9 @@ examples: - fileName: index-table-without-checkboxes.tsx title: Without checkboxes description: An index table without checkboxes and bulk actions. + - fileName: index-table-with-subheaders.tsx + title: With subheaders + description: An index table with multiple table headers. Use to present merchants with resources grouped by a relevant data value to enable faster bulk selection. previewImg: /images/components/tables/index-table.png --- @@ -98,19 +101,19 @@ Index tables can also: Using an index table in a project involves combining the following components and subcomponents: - IndexTable -- [IndexTableRow](#index-table-row) -- [IndexTableCell](#index-table-cell) +- [IndexTable.Row](#index-table-row) +- [IndexTable.Cell](#index-table-cell) - [Filters](/components/selection-and-input/filters) (optional) - [IndexFilters](/components/selection-and-input/index-filters) (optional) -- Pagination component (optional) +- [Pagination](/components/navigation/pagination) (optional) -The index table component provides the UI elements for list sorting, filtering, and pagination, but doesn’t provide the logic for these operations. When a sort option is changed, filter added, or second page requested, you’ll need to handle that event (including any network requests) and then update the component with new props. +The index table component provides the UI elements for list selection, sorting, filtering, and pagination, but doesn’t provide the logic for these operations. When a sort option is changed, filter added, or second page requested, you’ll need to handle that event (including any network requests) and then update the component with new props. --- ## Purpose -Shopify is organized around objects that represent merchants businesses, like customers, products, and orders. Each individual order, for example, is given a dedicated page that can be linked to. In Shopify, we call these types of objects _resources_, and we call the object’s dedicated page its _details page_. +Shopify is organized around objects that represent merchants' businesses, like customers, products, and orders. Each individual order, for example, is given a dedicated page that can be linked to. In Shopify, we call these types of objects _resources_, and we call the object’s dedicated page its _details page_. ### Problem @@ -186,37 +189,71 @@ Index tables should: --- -## IndexTableRow +## IndexTable.Row -An `IndexTableRow` is used to render a row representing an item within an `IndexTable` +An `IndexTable.Row` is used to render a row representing an item within an `IndexTable` -### IndexTableRow properties +### IndexTable.Row properties -| Prop | Type | Description | -| -------- | ---------- | ---------------------------------------------------------------- | -| id | string | A unique identifier for the row | -| selected | boolean | A boolean property indicating whether the row is selected | -| position | number | The index position of the row | -| subdued | boolean | A boolean property indicating whether the row should be subdued | -| status | RowStatus | A property indicating whether the row should have a status | -| disabled | boolean | A boolean property indicating whether the row should be disabled | -| onClick | () => void | A function which overrides the default click behaviour | +| Prop | Type | Description | +| ------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| children | ReactNode | Table header or data cells | +| id | string | A unique identifier for the row | +| selected? | boolean | "indeterminate" | A boolean property indicating whether the row or it's related rows are selected | +| position | number | The zero-indexed position of the row. Used for Shift key multi-selection as well as selection of a range of rows when a `selectionRange` is set. | +| subdued? | boolean | Whether the row should be subdued | +| status? | "success" | "subdued" | "critical" | Whether the row should have a status | +| disabled? | boolean | Whether the row should be disabled | +| selectionRange? | [number, number] | A tuple array with the first and last index of the range of other rows that the row describes. All non-disabled rows in the range are selected when the row with a selection range set is selected. | +| rowType? | "data" | "subheader" | Indicates the relationship or role of the row's contents. A `rowType` of "subheader" looks and behaves the same as the table header. Defaults to "data". | +| accessibilityLabel? | string | Label set on the row's checkbox. Defaults to "Select \{resourceName\}" | +| onClick? | () => void | Callback fired when the row is clicked. Overrides the default click behaviour. | +| onNavigation? | (id: string) => void | Callback fired when the row is clicked and contains an anchor element with the `data-primary-link` property set | -## IndexTableCell +## IndexTable.Cell -An `IndexTableCell` is used to render a single cell within an `IndexTableRow` +An `IndexTable.Cell` is used to render a single cell within an `IndexTable.Row` -### IndexTableCell properties +### IndexTable.Cell properties -| Prop | Type | Description | -| --------- | ------- | -------------------------------------------------------------------------------- | -| flush | boolean | A boolean property indicating whether the cell should remove the default padding | -| className | string | Adds a class to the cell, used for setting widths of a cell | +| Prop | Type | Description | +| ---------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| as? | 'th' | 'td' | The table cell element to render. Render the cell as a `th` if it serves as a subheading. Defaults to `td`. | +| id? | string | The unique ID to set on the cell element | +| children? | ReactNode | The cell contents | +| className? | string | Adds a class to the cell. Use to set a custom cell width. | +| flush? | boolean | Whether the cell padding should be removed. Defaults to false. | +| colSpan? | [HTMLTableCellElement['colSpan']](https://www.w3schools.com/tags/att_colspan.asp) | For subheader cells -- The number of the columns that the cell element should extend to within the row. | +| scope? | [HTMLTableCellElement['scope']](https://www.w3schools.com/tags/att_scope.asp) | For subheader cells -- Indicates the cells that the `th` element relates to | +| headers? | [HTMLTableCellElement['headers']](https://www.w3schools.com/tags/att_headers.asp) | A space-separated list of the `th` cell IDs that describe or apply to it. Use for cells within a row that relate to a subheader cell in addition to their column header. | --- ## Related components -- To create an actionable list of related items that link to details pages, such as a list of customers, use the [resource list component](https://polaris.shopify.com/components/resource-list) -- To present structured data for comparison and analysis, like when helping merchants to gain insights or review analytics, use the [data table component](https://polaris.shopify.com/components/data-table) +- To create an actionable list of related items that link to details pages, such as a list of customers, use the [resource list component](https://polaris.shopify.com/components/lists/resource-list) +- To present structured data for comparison and analysis, like when helping merchants to gain insights or review analytics, use the [data table component](https://polaris.shopify.com/components/tables/data-table) - To display a simple list of related content, [use the list component](https://polaris.shopify.com/components/lists/list) + +--- + +## Accessibility + +### Structure + +The `IndexTable` is an actionable, filterable, and sortable table widget that supports row selection with [subheaders](https://www.w3.org/WAI/tutorials/tables/multi-level/). To ensure that the power of this table is accessible to all merchants when implementing `IndexTable.Row` subheaders, set the following props on `IndexTable.Cell` that are appropriate for the enhancement you are implementing. + +Merchants can select a group of rows at once by clicking or Space keypressing a subheader row's checkbox. To indicate that an `IndexTable.Row` serves as a subheader for 1 or more rows below it, set the: + +- Zero-indexed table `position` of the first and last `IndexTable.Row` described by the subheader `IndexTable.Row` as a tuple array on its `subHeaderRange` prop +- Unique `id` on the `IndexTable.Cell` that contains the subheader content +- Element tag to `"th"` on the `as` prop of the subheader `IndexTable.Cell` +- Subheader `IndexTable.Cell` `scope` prop to `"colgroup"` + +To associate the subheader `IndexTable.Row` with each `IndexTable.Cell` that it describes, set the: + +- Unique `id` provided to the subheader `IndexTable.Cell` on the `headers` prop of each related `IndexTable.Cell` (contained by an `IndexTable.Row` that's position is within the `subHeaderRange`) as well as the unique `id` of its corresponding column heading that you provided to the `IndexTable` `headings` prop + +### Keyboard support + +`IndexTable` also supports multi-selection of a range of rows by keypressing the Shift key. To select a range, press and hold the Shift key while you click or keypress the Space key on a row checkbox and then do the same on another row's checkbox. All selectable rows between the selected checkboxes will also be selected. diff --git a/polaris.shopify.com/content/patterns/app-settings-layout/variants/default.md b/polaris.shopify.com/content/patterns/app-settings-layout/variants/default.md index 92ed228f586..72bf3fa645d 100644 --- a/polaris.shopify.com/content/patterns/app-settings-layout/variants/default.md +++ b/polaris.shopify.com/content/patterns/app-settings-layout/variants/default.md @@ -30,7 +30,7 @@ This pattern uses the [`BlockStack`](/components/layout-and-structure/block-stac {/* prettier-ignore */} ```javascript {"type":"previewContext","for":"example"} -
+
{(____CODE____)()}
``` diff --git a/polaris.shopify.com/content/patterns/resource-details-layout/variants/default.md b/polaris.shopify.com/content/patterns/resource-details-layout/variants/default.md index 31251a43b88..a743657a2a0 100644 --- a/polaris.shopify.com/content/patterns/resource-details-layout/variants/default.md +++ b/polaris.shopify.com/content/patterns/resource-details-layout/variants/default.md @@ -32,7 +32,7 @@ This pattern uses the [`Card`](/components/layout-and-structure/card), [`BlockSt {/* prettier-ignore */} ```javascript {"type":"previewContext","for":"example"} -
+
{(____CODE____)()}
``` diff --git a/polaris.shopify.com/content/patterns/resource-index-layout/variants/default.md b/polaris.shopify.com/content/patterns/resource-index-layout/variants/default.md index 6e3be2bf250..9f4e864fd7c 100644 --- a/polaris.shopify.com/content/patterns/resource-index-layout/variants/default.md +++ b/polaris.shopify.com/content/patterns/resource-index-layout/variants/default.md @@ -30,7 +30,7 @@ This pattern uses the [`Card`](/components/layout-and-structure/card), [`Badge`] {/* prettier-ignore */} ```javascript {"type":"previewContext","for":"example"} -
+
{(____CODE____)()}
``` diff --git a/polaris.shopify.com/next.config.js b/polaris.shopify.com/next.config.js index f31f1c590c3..9bf057f61a2 100644 --- a/polaris.shopify.com/next.config.js +++ b/polaris.shopify.com/next.config.js @@ -5,7 +5,7 @@ const path = require('path'); const nextConfig = { // See: https://nextjs.org/docs/advanced-features/output-file-tracing#automatically-copying-traced-files output: 'standalone', - reactStrictMode: true, + reactStrictMode: false, eslint: { ignoreDuringBuilds: true, }, @@ -139,6 +139,11 @@ const nextConfig = { destination: '/patterns#legacy', permanent: true, }, + { + source: '/coming-soon', + destination: '/coming-soon/view-transitions', + permanent: false, + }, ...actions, ...deprecated, ...feedbackIndicators, diff --git a/polaris.shopify.com/pages/_app.tsx b/polaris.shopify.com/pages/_app.tsx index 461cdada7dd..00efecf4313 100644 --- a/polaris.shopify.com/pages/_app.tsx +++ b/polaris.shopify.com/pages/_app.tsx @@ -1,7 +1,7 @@ import type {AppProps} from 'next/app'; import Head from 'next/head'; import Script from 'next/script'; -import {useEffect} from 'react'; +import {useEffect, StrictMode} from 'react'; import {useRouter} from 'next/router'; import useDarkMode from 'use-dark-mode'; import '@shopify/polaris/build/esm/styles.css'; @@ -9,6 +9,7 @@ import '@shopify/polaris/build/esm/styles.css'; import {className} from '../src/utils/various'; import Frame from '../src/components/Frame'; import '../src/styles/globals.scss'; +import ViewTransition from '../src/components/ViewTransition'; const PUBLIC_GA_ID = 'UA-49178120-32'; @@ -90,30 +91,32 @@ function MyApp({Component, pageProps}: AppProps) { ) : null} - <> - - - - - - - - -
- {isPolarisExample || isPolarisSandbox ? ( - - ) : ( - - - - )} -
- + + + + + + + + +
+ {isPolarisExample || isPolarisSandbox ? ( + + ) : ( + + + + + + + + )} +
); } diff --git a/polaris.shopify.com/pages/examples/index-table-with-subheaders.tsx b/polaris.shopify.com/pages/examples/index-table-with-subheaders.tsx new file mode 100644 index 00000000000..bc5c3e571d7 --- /dev/null +++ b/polaris.shopify.com/pages/examples/index-table-with-subheaders.tsx @@ -0,0 +1,242 @@ +import { + LegacyCard, + Text, + useIndexResourceState, + IndexTable, +} from '@shopify/polaris'; +import type {IndexTableRowProps, IndexTableProps} from '@shopify/polaris'; +import React, {Fragment} from 'react'; +import {withPolarisExample} from '../../src/components/PolarisExampleWrapper'; + +export function WithSubHeadersExample() { + interface Customer { + id: string; + url: string; + name: string; + location: string; + orders: number; + amountSpent: string; + lastOrderDate: string; + disabled?: boolean; + } + + interface CustomerRow extends Customer { + position: number; + } + + interface CustomerGroup { + id: string; + position: number; + customers: CustomerRow[]; + } + + interface Groups { + [key: string]: CustomerGroup; + } + + const rows = [ + { + id: '3411', + url: '#', + name: 'Mae Jemison', + location: 'Decatur, USA', + orders: 11, + amountSpent: '$2,400', + lastOrderDate: 'May 31, 2023', + }, + { + id: '2562', + url: '#', + name: 'Ellen Ochoa', + location: 'Los Angeles, USA', + orders: 30, + amountSpent: '$975', + lastOrderDate: 'May 31, 2023', + }, + { + id: '4102', + url: '#', + name: 'Colm Dillane', + location: 'New York, USA', + orders: 27, + amountSpent: '$2885', + lastOrderDate: 'May 31, 2023', + }, + { + id: '2564', + url: '#', + name: 'Al Chemist', + location: 'New York, USA', + orders: 19, + amountSpent: '$1,209', + lastOrderDate: 'April 4, 2023', + disabled: true, + }, + { + id: '2563', + url: '#', + name: 'Larry June', + location: 'San Francisco, USA', + orders: 22, + amountSpent: '$1,400', + lastOrderDate: 'March 19, 2023', + }, + ]; + + const columnHeadings = [ + {title: 'Name', id: 'name'}, + {title: 'Location', id: 'location'}, + { + alignment: 'end', + id: 'order-count', + title: 'Order count', + }, + { + alignment: 'end', + hidden: false, + id: 'amount-spent', + title: 'Amount spent', + }, + ]; + + const groupRowsByLastOrderDate = () => { + let position = -1; + const groups: Groups = (rows as Customer[]).reduce( + (groups: Groups, customer: Customer) => { + const {lastOrderDate} = customer; + if (!groups[lastOrderDate]) { + position += 1; + + groups[lastOrderDate] = { + position, + customers: [], + id: `order-${lastOrderDate.split(' ').join('-')}`, + }; + } + + groups[lastOrderDate].customers.push({ + ...customer, + position: position + 1, + }); + + position += 1; + return groups; + }, + {}, + ); + + return groups; + }; + + const resourceName = { + singular: 'customer', + plural: 'customers', + }; + + const {selectedResources, allResourcesSelected, handleSelectionChange} = + useIndexResourceState(rows, {resourceFilter: ({disabled}) => !disabled}); + + const orders = groupRowsByLastOrderDate(); + + const rowMarkup = Object.keys(orders).map((orderDate, index) => { + const {customers, position, id: subheaderId} = orders[orderDate]; + let selected: IndexTableRowProps['selected'] = false; + + const someCustomersSelected = customers.some(({id}) => + selectedResources.includes(id), + ); + + const allCustomersSelected = customers.every(({id}) => + selectedResources.includes(id), + ); + + if (allCustomersSelected) { + selected = true; + } else if (someCustomersSelected) { + selected = 'indeterminate'; + } + + const selectableRows = rows.filter(({disabled}) => !disabled); + const childRowRange: IndexTableRowProps['selectionRange'] = [ + selectableRows.findIndex((row) => row.id === customers[0].id), + selectableRows.findIndex( + (row) => row.id === customers[customers.length - 1].id, + ), + ]; + + return ( + + + + {`Last order placed: ${orderDate}`} + + + {customers.map( + ( + {id, name, location, orders, amountSpent, position, disabled}, + rowIndex, + ) => { + return ( + + + + {name} + + + {location} + + + {orders} + + + + + {amountSpent} + + + + ); + }, + )} + + ); + }); + + return ( + + + {rowMarkup} + + + ); +} + +export default withPolarisExample(WithSubHeadersExample); diff --git a/polaris.shopify.com/src/components/ComponentExamples/ComponentExamples.tsx b/polaris.shopify.com/src/components/ComponentExamples/ComponentExamples.tsx index 19ae9132365..efcab735ae6 100644 --- a/polaris.shopify.com/src/components/ComponentExamples/ComponentExamples.tsx +++ b/polaris.shopify.com/src/components/ComponentExamples/ComponentExamples.tsx @@ -51,7 +51,6 @@ function formatHTML(html: string): string { const ComponentExamples = ({examples}: Props) => { const [selectedIndex, setSelectedIndex] = useState(0); const [htmlCode, setHTMLCode] = useState(''); - const [iframeHeight, setIframeHeight] = useState(400); const handleExampleLoad = () => { diff --git a/polaris.shopify.com/src/components/ExampleWrapper/ExampleWrapper.module.scss b/polaris.shopify.com/src/components/ExampleWrapper/ExampleWrapper.module.scss index bd36534face..1ea7ec6ab18 100644 --- a/polaris.shopify.com/src/components/ExampleWrapper/ExampleWrapper.module.scss +++ b/polaris.shopify.com/src/components/ExampleWrapper/ExampleWrapper.module.scss @@ -30,5 +30,10 @@ @media screen and (max-width: $breakpointMobile) { min-width: 100%; } + + &.maximize { + min-width: 100%; + min-height: 100%; + } } } diff --git a/polaris.shopify.com/src/components/ExampleWrapper/ExampleWrapper.tsx b/polaris.shopify.com/src/components/ExampleWrapper/ExampleWrapper.tsx index 4c5d47b2aac..f298381f1ca 100644 --- a/polaris.shopify.com/src/components/ExampleWrapper/ExampleWrapper.tsx +++ b/polaris.shopify.com/src/components/ExampleWrapper/ExampleWrapper.tsx @@ -3,6 +3,8 @@ import {Box, type WithAsProp} from '../Box'; import styles from './ExampleWrapper.module.scss'; type Props = { + as?: string; + className?: string; children: React.ReactNode; renderFrameActions?: () => React.ReactNode; }; diff --git a/polaris.shopify.com/src/components/ExampleWrapper/LinkButton/LinkButton.module.scss b/polaris.shopify.com/src/components/ExampleWrapper/LinkButton/LinkButton.module.scss index fd3648a3411..aba805cb41c 100644 --- a/polaris.shopify.com/src/components/ExampleWrapper/LinkButton/LinkButton.module.scss +++ b/polaris.shopify.com/src/components/ExampleWrapper/LinkButton/LinkButton.module.scss @@ -1,8 +1,20 @@ .Link { background: transparent; font-size: var(--font-size-100); + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border-radius: 4px; + &:hover, &:focus { color: var(--text-link); } + + &.dark-mode { + .Icon { + filter: none; + } + } } diff --git a/polaris.shopify.com/src/components/ExampleWrapper/LinkButton/LinkButton.tsx b/polaris.shopify.com/src/components/ExampleWrapper/LinkButton/LinkButton.tsx index 826a10cf7ed..9be4f603314 100644 --- a/polaris.shopify.com/src/components/ExampleWrapper/LinkButton/LinkButton.tsx +++ b/polaris.shopify.com/src/components/ExampleWrapper/LinkButton/LinkButton.tsx @@ -1,9 +1,19 @@ import React from 'react'; import styles from './LinkButton.module.scss'; +import {className as classNames} from '../../../utils/various'; + type Props = React.ComponentProps<'button'>; -const LinkButton: React.ComponentType = (props) => { - return diff --git a/polaris.shopify.com/src/components/Page/Page.module.scss b/polaris.shopify.com/src/components/Page/Page.module.scss index 272f120b801..b6e2dfa36b8 100644 --- a/polaris.shopify.com/src/components/Page/Page.module.scss +++ b/polaris.shopify.com/src/components/Page/Page.module.scss @@ -21,6 +21,7 @@ } .Page { + view-transition-name: page; --toc-width: 16rem; display: flex; gap: 2.5rem; diff --git a/polaris.shopify.com/src/components/PatternsExample/PatternsExample.module.scss b/polaris.shopify.com/src/components/PatternsExample/PatternsExample.module.scss index 3643641e350..df9303ee9d9 100644 --- a/polaris.shopify.com/src/components/PatternsExample/PatternsExample.module.scss +++ b/polaris.shopify.com/src/components/PatternsExample/PatternsExample.module.scss @@ -1,5 +1,7 @@ @import '../../styles/variables.scss'; .Link { + display: flex; + align-items: center; font-size: var(--font-size-100); color: var(--text-default); &:hover, @@ -7,16 +9,119 @@ color: var(--text-link); } } -.SpecificityBuster { + +.PositionedLink { + right: 1rem; + top: 1rem; + position: absolute; + z-index: 2; + border-radius: 4px; + width: 32px; + height: 32px; + transition: opacity 0.3s ease-in-out; +} + +.ExampleWrapper { + position: relative; + box-shadow: none; + border: 1px solid var(--p-color-border-subdued); + border-radius: 8px; + + @media screen and (max-width: $breakpointMobile) { + border-left: 0; + border-right: 0; + margin-left: -1.25rem; + margin-right: -1.25rem; + border-radius: 0; + } +} + +.Dialog { + view-transition-name: dialog; + + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + + &.open { + opacity: 1; + } + + &::backdrop { + background: var(--p-color-bg-inverse); + opacity: 0.1; + width: 100vw; + } + + &:modal { + max-width: 100vw; + max-height: 100vh; + } + .ExampleWrapper { - box-shadow: none; - border: 1px solid var(--p-color-border-subdued); - @media screen and (max-width: $breakpointMobile) { - border-left: 0; - border-right: 0; - margin-left: -1.25rem; - margin-right: -1.25rem; - border-radius: 0; + border-radius: 0; + height: 100%; + z-index: 13; + overscroll-behavior: contain; + + iframe { + width: 100%; + height: calc(100% - 2.75rem); } } + + .PositionedLink { + z-index: 14; + } + + .PreventBackgroundInteractions { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + } } + +// ::view-transition-new(dialog) { +// animation: 200ms ease-in both var(--p-motion-keyframes-fade-in), +// 300ms cubic-bezier(0.34, 1.56, 0.64, 1) both show; +// } + +// ::view-transition-old(dialog) { +// animation: 200ms ease-out both fade-out, 300ms ease-out both hide; +// } + +// @keyframes fade-out { +// from { +// opacity: 1; +// } +// to { +// opacity: 0; +// } +// } + +// @keyframes hide { +// from { +// transform: translateX(350px) translateY(0); +// } + +// to { +// transform: translateX(0) translateY(350px); +// } +// } + +// @keyframes show { +// from { +// transform: translateX(0) translateY(350px); +// } + +// to { +// transform: translateX(350px) translateY(0); +// } +// } diff --git a/polaris.shopify.com/src/components/PatternsExample/PatternsExample.tsx b/polaris.shopify.com/src/components/PatternsExample/PatternsExample.tsx index c9f0235698f..6690b2cabb9 100644 --- a/polaris.shopify.com/src/components/PatternsExample/PatternsExample.tsx +++ b/polaris.shopify.com/src/components/PatternsExample/PatternsExample.tsx @@ -1,4 +1,4 @@ -import {Fragment, useEffect, useState} from 'react'; +import {Fragment, useRef, useEffect, useState, useCallback} from 'react'; import {format} from 'prettier/standalone'; import babel from 'prettier/parser-babel'; import endent from 'endent'; @@ -9,6 +9,10 @@ import GrowFrame from '../GrowFrame'; import Code from '../Code'; import ExampleWrapper, {LinkButton} from '../ExampleWrapper'; import {PatternExample} from '../../types'; +import {className as classNames} from '../../utils/various'; +import {viewTransition} from '../../utils/various'; +import {MaximizeMinor, MinimizeMinor} from '@shopify/polaris-icons'; +import Icon from '../Icon'; const getISOStringYear = () => new Date().toISOString().split('T')[0]; @@ -63,15 +67,99 @@ const PatternsExample = ({ minHeight?: string; onCodeVisibilityToggle?: () => void; }) => { - const isControlled = typeof isCodeVisible === 'undefined'; + const expandedPreviewRef: React.RefObject = useRef(null); + const previewRef: React.RefObject = useRef(null); + const [codeActive, toggleCode] = useState(false); + const [dialogActive, toggleDialog] = useState(false); + + const isControlled = typeof isCodeVisible === 'undefined'; const showCodeValue = isControlled ? codeActive : isCodeVisible; + + const handleScrollLock = (lock: boolean) => { + if (lock) { + document + ?.querySelector('html') + ?.setAttribute('style', 'overflow: hidden;'); + } else { + document?.querySelector('html')?.removeAttribute('style'); + } + }; + const handleCodeToggle = () => { if (onCodeVisibilityToggle) onCodeVisibilityToggle(); if (isControlled) { toggleCode((codeActive) => !codeActive); } }; + + const handleMaximize = async () => { + const dialog = expandedPreviewRef.current; + const preview = previewRef.current; + + if (preview) { + // @ts-ignore + preview.style.viewTransitionName = 'dialog'; + } + + const maximize = viewTransition(() => { + if (dialog) { + handleScrollLock(true); + toggleDialog(true); + dialog.showModal(); + } + + if (preview) { + // @ts-ignore + preview.style.viewTransitionName = ''; + } + }); + + await maximize.finished; + }; + + const handleMinimize = useCallback(async () => { + const dialog = expandedPreviewRef.current; + const preview = previewRef.current; + + try { + const minimize = viewTransition(() => { + if (dialog) { + handleScrollLock(false); + toggleDialog(false); + dialog.close(); + + if (preview) { + // @ts-ignore + preview.style.viewTransitionName = 'dialog'; + } + } + }); + + await minimize.finished; + } finally { + if (preview) { + // @ts-ignore + preview.style.viewTransitionName = ''; + } + } + }, []); + + /* Escape to close with the element is supposed to "just work", but it only worked here when the backdrop was the active element. Leaving this for now even though it doesn't work. is an anomoly because of the top-layer... */ + useEffect(() => { + if (dialogActive) { + const handleKeyDownEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleMinimize(); + } + }; + + window.addEventListener('keyup', handleKeyDownEscape); + + return () => window.removeEventListener('keyup', handleKeyDownEscape); + } + }, [dialogActive, handleMinimize]); + const formatCodeSnippet = (code: string) => { let prettifiedCode; @@ -130,22 +218,64 @@ const PatternsExample = ({ paramType: 'search', })}`; - return ( - + const exampleMarkup = ( + ( + + + + + + + {showCodeValue ? 'Hide code' : 'Show code'} + + + ) + : undefined + } + > + + + ); + + const expandedExampleMarkup = ( + +
( - - - - {showCodeValue ? 'Hide code' : 'Show code'} - - - ) - : undefined - } + renderFrameActions={() => ( + + + + + + + )} > - {showCodeValue ? ( - + ); + + return ( + <> + {expandedExampleMarkup} + + {exampleMarkup} + {showCodeValue ? ( + - ) : null} - + }, + ]} + /> + ) : null} + + ); }; diff --git a/polaris.shopify.com/src/components/TokenList/TokenList.tsx b/polaris.shopify.com/src/components/TokenList/TokenList.tsx index 65878bb082f..fdf435dbf71 100644 --- a/polaris.shopify.com/src/components/TokenList/TokenList.tsx +++ b/polaris.shopify.com/src/components/TokenList/TokenList.tsx @@ -232,7 +232,7 @@ function TokenPreview({name, value}: TokenPreviewProps) { }; // Colors - if (value.startsWith('rgba')) { + if (value.startsWith('rgba') || value.includes('color-')) { return (
{ + const handler = () => { + if (document.startViewTransition !== undefined) { + return document.startViewTransition(async () => { + return await new Promise((resolve) => { + return resolve(true); + }); + }); + } + }; + + // router.events.on('routeChangeStart', handler); + + return () => { + // router.events.off('routeChangeStart', handler); + }; + }, []); + + return children; +} diff --git a/polaris.shopify.com/src/components/ViewTransition/index.ts b/polaris.shopify.com/src/components/ViewTransition/index.ts new file mode 100644 index 00000000000..8609a87f1f0 --- /dev/null +++ b/polaris.shopify.com/src/components/ViewTransition/index.ts @@ -0,0 +1,3 @@ +import ViewTransition from './ViewTransition'; + +export default ViewTransition; diff --git a/polaris.shopify.com/src/utils/various.ts b/polaris.shopify.com/src/utils/various.ts index b98e5205810..8e5462f7a4b 100644 --- a/polaris.shopify.com/src/utils/various.ts +++ b/polaris.shopify.com/src/utils/various.ts @@ -134,3 +134,19 @@ export function getResponsiveProps( ]), ) as unknown as ResponsiveVariables; } +export const viewTransition = (callback: () => void | Promise) => { + // @ts-ignore is experimental and not typed yet + if (document.startViewTransition) { + // @ts-ignore exists in Chrome 111+ + return document.startViewTransition(callback); + } else { + callback(); + + const resolved = Promise.resolve(); + + return { + ready: resolved, + finished: resolved, + }; + } +};