diff --git a/packages/react-components/react-combobox/stories/Listbox/ListboxDefault.stories.tsx b/packages/react-components/react-combobox/stories/Listbox/ListboxDefault.stories.tsx new file mode 100644 index 0000000000000..01fd9f5b6f808 --- /dev/null +++ b/packages/react-components/react-combobox/stories/Listbox/ListboxDefault.stories.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { Listbox, makeStyles, Option, shorthands } from '@fluentui/react-components'; +import type { ListboxProps } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + // Stack the label above the field with a gap + display: 'grid', + gridTemplateRows: 'repeat(1fr)', + justifyItems: 'start', + ...shorthands.gap('2px'), + maxWidth: '400px', + }, +}); + +export const Default = (props: Partial) => { + const options = ['Cat', 'Dog', 'Ferret', 'Fish', 'Hamster', 'Snake']; + + const styles = useStyles(); + return ( +
+ + {options.map(option => ( + + ))} + +
+ ); +}; diff --git a/packages/react-components/react-combobox/stories/Listbox/ListboxDescription.md b/packages/react-components/react-combobox/stories/Listbox/ListboxDescription.md new file mode 100644 index 0000000000000..db39dca2c9a77 --- /dev/null +++ b/packages/react-components/react-combobox/stories/Listbox/ListboxDescription.md @@ -0,0 +1 @@ +A listbox (`Listbox`) provides people a way to select an option from an inline list. diff --git a/packages/react-components/react-combobox/stories/Listbox/ListboxFiltering.stories.tsx b/packages/react-components/react-combobox/stories/Listbox/ListboxFiltering.stories.tsx new file mode 100644 index 0000000000000..add6741bb6f5a --- /dev/null +++ b/packages/react-components/react-combobox/stories/Listbox/ListboxFiltering.stories.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { Button, Input, Listbox, makeStyles, mergeClasses, Option, shorthands } from '@fluentui/react-components'; +import type { ListboxProps } from '@fluentui/react-components'; +import { DismissRegular } from '@fluentui/react-icons'; + +const useStyles = makeStyles({ + root: { + // Stack the label above the field with a gap + display: 'grid', + gridTemplateRows: 'repeat(1fr)', + justifyItems: 'start', + ...shorthands.gap('2px'), + width: '200px', + }, + input: { + width: '100%', + marginBottom: '8px', + }, + listbox: { + width: '100%', + }, +}); + +export const Filtering = (props: Partial) => { + const [filter, setFilter] = React.useState(''); + + const filteredOptions = React.useMemo(() => { + return ['Cat', 'Dog', 'Ferret', 'Fish', 'Hamster', 'Snake'].filter( + o => o.toLowerCase().includes(filter.toLowerCase()) || filter.toLowerCase().includes(o.toLowerCase()), + ); + }, [filter]); + + const styles = useStyles(); + return ( +
+ setFilter(data.value)} + contentAfter={ + filter.length > 0 ? ( +
+ ); +}; diff --git a/packages/react-components/react-combobox/stories/Listbox/ListboxFilteringInDialog.stories.tsx b/packages/react-components/react-combobox/stories/Listbox/ListboxFilteringInDialog.stories.tsx new file mode 100644 index 0000000000000..06521e962b0e9 --- /dev/null +++ b/packages/react-components/react-combobox/stories/Listbox/ListboxFilteringInDialog.stories.tsx @@ -0,0 +1,170 @@ +import * as React from 'react'; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Input, + Listbox, + makeStyles, + MenuButton, + mergeClasses, + Option, + shorthands, + tokens, +} from '@fluentui/react-components'; +import type { ListboxProps } from '@fluentui/react-components'; +import { DismissRegular } from '@fluentui/react-icons'; + +const useStyles = makeStyles({ + root: { + // Stack the label above the field with a gap + display: 'grid', + gridTemplateRows: 'repeat(1fr)', + justifyItems: 'start', + ...shorthands.gap('2px'), + maxWidth: '400px', + }, + menuButton: { + justifyContent: 'space-between', + }, + listboxWrapper: { + maxHeight: '200px', + ...shorthands.overflow('hidden', 'auto'), + }, + inputWrapper: { + display: 'flex', + alignItems: 'center', + position: 'sticky', + top: '-4px', + backgroundColor: tokens.colorNeutralBackground1, + zIndex: 1, + ...shorthands.margin('-4px', '-4px', '0px'), + ...shorthands.padding('4px', '4px', '8px'), + }, + input: { + width: '100%', + }, + listbox: { + width: '100%', + }, + option: { + scrollMarginBlockStart: '48px', + }, +}); + +export const FilteringInDialog = (props: Partial) => { + const inputRef = React.useRef(null); + const [isMultiselect, setIsMultiselect] = React.useState(props.multiselect); + const [selectedOptions, setSelectedOptions] = React.useState(props.selectedOptions ?? []); + const [pendingSelectedOptions, setPendingSelectedOptions] = React.useState(props.selectedOptions ?? []); + + const [dialogOpen, setDialogOpen] = React.useState(false); + const [filter, setFilter] = React.useState(''); + + const filteredOptions = React.useMemo(() => { + return ['Bear', 'Cat', 'Dog', 'Ferret', 'Fish', 'Hamster', 'Monkey', 'Parrot', 'Snake', 'Zebra'].filter( + o => o.toLowerCase().includes(filter.toLowerCase()) || filter.toLowerCase().includes(o.toLowerCase()), + ); + }, [filter]); + + React.useEffect(() => { + setSelectedOptions(props.selectedOptions ?? []); + }, [props.selectedOptions]); + + React.useEffect(() => { + setSelectedOptions(selection => (selection[0] ? [selection[0]] : [])); + }, [isMultiselect]); + + React.useEffect(() => { + if (dialogOpen) { + inputRef.current?.focus({ preventScroll: true }); + } + }, [dialogOpen]); + + const styles = useStyles(); + return ( +
+ setIsMultiselect(data.checked !== false)} + /> + setDialogOpen(data.open)}> + + setPendingSelectedOptions(selectedOptions)} + > + {selectedOptions.length > 0 ? selectedOptions.join(', ') : 'Select a pet'} + + + + + Select a pet + +
+ setFilter(data.value)} + contentAfter={ + filter.length > 0 ? ( +
+ { + setPendingSelectedOptions(data.selectedOptions); + }} + > + {filteredOptions.map(option => ( + + ))} + +
+ + + + + + +
+
+
+
+ ); +}; diff --git a/packages/react-components/react-combobox/stories/Listbox/ListboxFilteringInPopover.stories.tsx b/packages/react-components/react-combobox/stories/Listbox/ListboxFilteringInPopover.stories.tsx new file mode 100644 index 0000000000000..ecfb7a05989a6 --- /dev/null +++ b/packages/react-components/react-combobox/stories/Listbox/ListboxFilteringInPopover.stories.tsx @@ -0,0 +1,167 @@ +import * as React from 'react'; +import { + Button, + Checkbox, + Input, + Listbox, + makeStyles, + MenuButton, + mergeClasses, + Option, + Popover, + PopoverSurface, + PopoverTrigger, + shorthands, + tokens, +} from '@fluentui/react-components'; +import type { ListboxProps } from '@fluentui/react-components'; +import { DismissRegular } from '@fluentui/react-icons'; + +const useStyles = makeStyles({ + root: { + // Stack the label above the field with a gap + display: 'grid', + gridTemplateRows: 'repeat(1fr)', + justifyItems: 'start', + ...shorthands.gap('2px'), + maxWidth: '400px', + }, + menuButton: { + justifyContent: 'space-between', + }, + listboxWrapper: { + maxHeight: '200px', + marginBottom: '8px', + ...shorthands.overflow('hidden', 'auto'), + }, + inputWrapper: { + display: 'flex', + alignItems: 'center', + position: 'sticky', + top: '-4px', + backgroundColor: tokens.colorNeutralBackground1, + zIndex: 1, + ...shorthands.margin('-4px', '-4px', '0px'), + ...shorthands.padding('4px', '4px', '8px'), + }, + input: { + width: '100%', + }, + listbox: { + width: '100%', + }, + option: { + scrollMarginBlockStart: '48px', + }, + doneButton: { + width: '100%', + }, +}); + +export const FilteringInPopover = (props: Partial) => { + const inputRef = React.useRef(null); + const [isMultiselect, setIsMultiselect] = React.useState(props.multiselect); + const [selectedOptions, setSelectedOptions] = React.useState(props.selectedOptions ?? []); + const [pendingSelectedOptions, setPendingSelectedOptions] = React.useState(props.selectedOptions ?? []); + + const [popoverOpen, setPopoverOpen] = React.useState(false); + const [filter, setFilter] = React.useState(''); + + const filteredOptions = React.useMemo(() => { + return ['Bear', 'Cat', 'Dog', 'Ferret', 'Fish', 'Hamster', 'Monkey', 'Parrot', 'Snake', 'Zebra'].filter( + o => o.toLowerCase().includes(filter.toLowerCase()) || filter.toLowerCase().includes(o.toLowerCase()), + ); + }, [filter]); + + React.useEffect(() => { + setSelectedOptions(props.selectedOptions ?? []); + }, [props.selectedOptions]); + + React.useEffect(() => { + setSelectedOptions(selection => (selection[0] ? [selection[0]] : [])); + }, [isMultiselect]); + + React.useEffect(() => { + if (popoverOpen) { + inputRef.current?.focus({ preventScroll: true }); + } + }, [popoverOpen]); + + const styles = useStyles(); + return ( +
+ setIsMultiselect(data.checked !== false)} + /> + setPopoverOpen(data.open)} + positioning={{ align: 'start' }} + > + + setPendingSelectedOptions(selectedOptions)} + > + {selectedOptions.length > 0 ? selectedOptions.join(', ') : 'Select a pet'} + + + +
+
+ setFilter(data.value)} + contentAfter={ + filter.length > 0 ? ( +
+ { + setPendingSelectedOptions(data.selectedOptions); + }} + > + {filteredOptions.map(option => ( + + ))} + +
+ +
+
+
+ ); +}; diff --git a/packages/react-components/react-combobox/stories/Listbox/ListboxInDialog.stories.tsx b/packages/react-components/react-combobox/stories/Listbox/ListboxInDialog.stories.tsx new file mode 100644 index 0000000000000..2ad5dd2000c54 --- /dev/null +++ b/packages/react-components/react-combobox/stories/Listbox/ListboxInDialog.stories.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Listbox, + makeStyles, + MenuButton, + mergeClasses, + Option, + shorthands, +} from '@fluentui/react-components'; +import type { ListboxProps } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + // Stack the label above the field with a gap + display: 'grid', + gridTemplateRows: 'repeat(1fr)', + justifyItems: 'start', + ...shorthands.gap('2px'), + maxWidth: '400px', + }, + menuButton: { + justifyContent: 'space-between', + }, + listboxWrapper: { + maxHeight: '200px', + ...shorthands.overflow('hidden', 'auto'), + }, + input: { + width: '100%', + }, + listbox: { + width: '100%', + }, +}); + +export const InDialog = (props: Partial) => { + const [isMultiselect, setIsMultiselect] = React.useState(props.multiselect); + const [selectedOptions, setSelectedOptions] = React.useState(props.selectedOptions ?? []); + const [pendingSelectedOptions, setPendingSelectedOptions] = React.useState(props.selectedOptions ?? []); + + const [dialogOpen, setDialogOpen] = React.useState(false); + const options = ['Bear', 'Cat', 'Dog', 'Ferret', 'Fish', 'Hamster', 'Monkey', 'Parrot', 'Snake', 'Zebra']; + + React.useEffect(() => { + setSelectedOptions(props.selectedOptions ?? []); + }, [props.selectedOptions]); + + React.useEffect(() => { + setSelectedOptions(selection => (selection[0] ? [selection[0]] : [])); + }, [isMultiselect]); + + const styles = useStyles(); + return ( +
+ setIsMultiselect(data.checked !== false)} + /> + setDialogOpen(data.open)}> + + setPendingSelectedOptions(selectedOptions)} + > + {selectedOptions.length > 0 ? selectedOptions.join(', ') : 'Select a pet'} + + + + + Select a pet + + { + setPendingSelectedOptions(data.selectedOptions); + }} + > + {options.map(option => ( + + ))} + + + + + + + + + + + +
+ ); +}; diff --git a/packages/react-components/react-combobox/stories/Listbox/ListboxMultiselect.stories.tsx b/packages/react-components/react-combobox/stories/Listbox/ListboxMultiselect.stories.tsx new file mode 100644 index 0000000000000..baa67da6bbcb2 --- /dev/null +++ b/packages/react-components/react-combobox/stories/Listbox/ListboxMultiselect.stories.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { Listbox, makeStyles, Option, shorthands } from '@fluentui/react-components'; +import type { ListboxProps } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + // Stack the label above the field with a gap + display: 'grid', + gridTemplateRows: 'repeat(1fr)', + justifyItems: 'start', + ...shorthands.gap('2px'), + maxWidth: '400px', + }, +}); + +export const Multiselect = (props: Partial) => { + const options = ['Cat', 'Dog', 'Ferret', 'Fish', 'Hamster', 'Snake']; + + const styles = useStyles(); + return ( +
+ + {options.map(option => ( + + ))} + +
+ ); +}; diff --git a/packages/react-components/react-combobox/stories/Listbox/index.stories.tsx b/packages/react-components/react-combobox/stories/Listbox/index.stories.tsx new file mode 100644 index 0000000000000..2a12f16d05465 --- /dev/null +++ b/packages/react-components/react-combobox/stories/Listbox/index.stories.tsx @@ -0,0 +1,26 @@ +import { Meta } from '@storybook/react'; +import { Listbox, Option } from '@fluentui/react-components'; + +import descriptionMd from './ListboxDescription.md'; + +export { Default } from './ListboxDefault.stories'; +export { Multiselect } from './ListboxMultiselect.stories'; +export { Filtering } from './ListboxFiltering.stories'; +export { InDialog } from './ListboxInDialog.stories'; +export { FilteringInDialog } from './ListboxFilteringInDialog.stories'; +export { FilteringInPopover } from './ListboxFilteringInPopover.stories'; + +export default { + title: 'Components/Listbox', + component: Listbox, + subcomponents: { + Option, + }, + parameters: { + docs: { + description: { + component: [descriptionMd].join('\n'), + }, + }, + }, +} as Meta;