Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More UI components and React icons #44

Merged
merged 9 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
27 changes: 26 additions & 1 deletion .figmaexportrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

// @ts-check

const { pascalCase } = require('@figma-export/utils')

module.exports = {
commands: [
[
Expand Down Expand Up @@ -37,7 +39,7 @@ module.exports = {
],
outputters: [
require('@figma-export/output-components-as-svg')({
output: './icons',
output: './icons/svg',
getDirname: () => '',
getBasename: ({ basename, dirname }) => {
// Special handing for the directional arrows which have an odd export naming convention
Expand All @@ -53,6 +55,29 @@ module.exports = {
return `${basename}.svg`
},
}),
require('@figma-export/output-components-as-svgr')({
output: './icons/react',
getFileExtension: () => '.tsx',
getDirname: () => '',
getComponentName: ({ componentName }) =>
pascalCase(
componentName
.split('/')
.map((n) => `${n[0].toUpperCase()}${n.slice(1)}`)
.reverse()
.join('') + 'Icon',
),
getSvgrConfig: () => {
return {
jsxRuntime: 'automatic',
typescript: true,
titleProp: true,
svgProps: {
role: 'img',
},
}
},
}),
],
},
],
Expand Down
60 changes: 60 additions & 0 deletions components/src/ui/checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { Checkmark12Icon } from '@/icons/react'
import cn from 'classnames'

import { classed } from '../../utils'

const Check = () => (
<Checkmark12Icon className="pointer-events-none absolute left-0.5 top-0.5 h-3 w-3 fill-current text-accent" />
)

const Indeterminate = classed.div`absolute w-2 h-0.5 left-1 top-[7px] bg-accent pointer-events-none`

const inputStyle = `
appearance-none border border-default bg-default h-4 w-4 rounded-sm absolute left-0 outline-none
disabled:cursor-not-allowed
hover:border-hover hover:cursor-pointer
checked:bg-accent-secondary checked:border-accent-secondary checked:hover:border-accent
indeterminate:bg-accent-secondary indeterminate:border-accent hover:indeterminate:bg-accent-secondary-hover
`

export type CheckboxProps = {
indeterminate?: boolean
children?: React.ReactNode
className?: string
} & Omit<React.ComponentProps<'input'>, 'type'>

// ref function is from: https://davidwalsh.name/react-indeterminate. this makes
// the native input work with indeterminate. you can't pass indeterminate as a
// prop; it has to be set directly on the element through a ref. more elaborate
// examples using forwardRef to allow passing ref from outside:
// https://github.com/tannerlinsley/react-table/discussions/1989

/** Checkbox component that handles label, styling, and indeterminate state */
export const Checkbox = ({
indeterminate,
children,
className,
...inputProps
}: CheckboxProps) => (
<label className="inline-flex items-center">
<span className="relative h-4 w-4">
<input
className={cn(inputStyle, className)}
type="checkbox"
ref={(el) => el && (el.indeterminate = !!indeterminate)}
{...inputProps}
/>
{inputProps.checked && !indeterminate && <Check />}
{indeterminate && <Indeterminate />}
</span>

{children && <span className="ml-2.5 text-sans-md text-secondary">{children}</span>}
</label>
)
53 changes: 53 additions & 0 deletions components/src/ui/empty-message/EmptyMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import cn from 'classnames'
import type { ReactElement } from 'react'
import { Link } from 'react-router-dom'

import { Button, buttonStyle } from '@oxide/design-system'

const buttonStyleProps = { variant: 'ghost', size: 'sm', color: 'secondary' } as const

type Props = {
icon?: ReactElement
title: string
body?: string
} & ( // only require buttonTo or onClick if buttonText is present
| { buttonText: string; buttonTo: string }
| { buttonText: string; onClick: () => void }
| { buttonText?: never }
)

export function EmptyMessage(props: Props) {
let button: ReactElement | null = null
if (props.buttonText && 'buttonTo' in props) {
button = (
<Link className={cn('mt-6', buttonStyle(buttonStyleProps))} to={props.buttonTo}>
{props.buttonText}
</Link>
)
} else if (props.buttonText && 'onClick' in props) {
button = (
<Button {...buttonStyleProps} className="mt-6" onClick={props.onClick}>
{props.buttonText}
</Button>
)
}
return (
<div className="m-4 flex max-w-[14rem] flex-col items-center text-center">
{props.icon && (
<div className="mb-4 rounded p-1 leading-[0] text-accent bg-accent-secondary">
{props.icon}
</div>
)}
<h3 className="text-sans-semi-lg">{props.title}</h3>
{props.body && <p className="mt-1 text-sans-md text-secondary">{props.body}</p>}
{button}
</div>
)
}
3 changes: 3 additions & 0 deletions components/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ export * from './badge/Badge'
export * from './button/Button'
export * from './spinner/Spinner'
export * from './tabs/Tabs'
export * from './checkbox/Checkbox'
export * from './empty-message/EmptyMessage'
export * from './listbox/Listbox'
149 changes: 149 additions & 0 deletions components/src/ui/listbox/Listbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { SelectArrows6Icon } from '@/icons/react'
import { FloatingPortal, flip, offset, size, useFloating } from '@floating-ui/react'
import { Listbox as Select } from '@headlessui/react'
import cn from 'classnames'
import type { ReactNode } from 'react'

import { SpinnerLoader } from '~/src'

export type ListboxItem<Value extends string = string> = {
value: Value
} & (
| { label: string; labelString?: never }
// labelString is required when `label` is a `ReactElement` because we
// need need a one-line string to display in the button when the item is
// selected.
| { label: ReactNode; labelString: string }
)

export interface ListboxProps<Value extends string = string> {
// null is allowed as a default empty value, but onChange will never be called with null
selected: Value | null
onChange: (value: Value) => void
items: ListboxItem<Value>[]
placeholder?: string
className?: string
disabled?: boolean
hasError?: boolean
name?: string
isLoading?: boolean
}

export const Listbox = <Value extends string = string>({
name,
selected,
items,
placeholder = 'Select an option',
className,
onChange,
hasError = false,
disabled,
isLoading = false,
...props
}: ListboxProps<Value>) => {
const { refs, floatingStyles } = useFloating({
middleware: [
offset(12),
flip(),
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
})
},
}),
],
})

const selectedItem = selected && items.find((i) => i.value === selected)
const noItems = !isLoading && items.length === 0
const isDisabled = disabled || noItems

return (
<div className={cn('relative', className)}>
<Select
value={selected}
// you shouldn't ever be able to select null, but we check here anyway
// to make TS happy so the calling code doesn't have to. note `val !==
// null` because '' is falsy but potentially a valid value
onChange={(val) => val !== null && onChange(val)}
disabled={isDisabled || isLoading}
>
{({ open }) => (
<>
<Select.Button
name={name}
ref={refs.setReference}
className={cn(
`flex h-10 w-full items-center justify-between
rounded border text-sans-md`,
hasError
? 'focus-error border-error-secondary hover:border-error'
: 'border-default hover:border-hover',
open && 'ring-2 ring-accent-secondary',
open && hasError && 'ring-error-secondary',
isDisabled
? 'cursor-not-allowed text-disabled bg-disabled !border-default'
: 'bg-default',
isDisabled && hasError && '!border-error-secondary',
)}
{...props}
>
<div className="w-full px-3 text-left">
{selectedItem ? (
// labelString is one line, which is what we need when label is a ReactNode
selectedItem.labelString || selectedItem.label
) : (
<span className="text-quaternary">
{noItems ? 'No items' : placeholder}
</span>
)}
</div>
{!isDisabled && <SpinnerLoader isLoading={isLoading} />}
<div
className="ml-3 flex h-[calc(100%-12px)] items-center border-l px-3 border-secondary"
aria-hidden
>
<SelectArrows6Icon className="h-[14px] w-2 text-tertiary" />
</div>
</Select.Button>
<FloatingPortal>
<Select.Options
ref={refs.setFloating}
style={floatingStyles}
className="ox-menu pointer-events-auto z-50 overflow-y-auto !outline-none"
>
{items.map((item) => (
<Select.Option
key={item.value}
value={item.value}
className="relative border-b border-secondary last:border-0"
>
{({ active, selected }) => (
<div
className={cn(
'ox-menu-item text-secondary',
selected && 'is-selected',
active && 'is-highlighted',
)}
>
{item.label}
</div>
)}
</Select.Option>
))}
</Select.Options>
</FloatingPortal>
</>
)}
</Select>
</div>
)
}
42 changes: 41 additions & 1 deletion components/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,44 @@ const titleCase = (text: string): string => {
)
}

export { titleCase }
// all the cuteness of tw.span`text-secondary uppercase` with zero magic

const make =
<T extends keyof JSX.IntrinsicElements>(tag: T) =>
// only one argument here means string interpolations are not allowed
(strings: TemplateStringsArray) => {
const Comp = ({ className, children, ...rest }: JSX.IntrinsicElements[T]) =>
React.createElement(tag, { className: cn(strings[0], className), ...rest }, children)
Comp.displayName = `classed.${tag}`
return Comp
}

// JSX.IntrinsicElements[T] ensures same props as the real DOM element. For example,
// classed.span doesn't allow a type attr but classed.input does.

const classed = {
button: make('button'),
div: make('div'),
h1: make('h1'),
h2: make('h2'),
h3: make('h3'),
h4: make('h4'),
hr: make('hr'),
header: make('header'),
input: make('input'),
label: make('label'),
li: make('li'),
main: make('main'),
ol: make('ol'),
p: make('p'),
span: make('span'),
table: make('table'),
tbody: make('tbody'),
td: make('td'),
th: make('th'),
tr: make('tr'),
} as const

// result: classed.button`text-secondary uppercase` returns a component with those classes

export { titleCase, classed }
11 changes: 11 additions & 0 deletions icons/react/Access16Icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SVGProps } from "react";
interface SVGRProps {
title?: string;
titleId?: string;
}
const Access16Icon = ({
title,
titleId,
...props
}: SVGProps<SVGSVGElement> & SVGRProps) => <svg width={16} height={16} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby={titleId} {...props}>{title ? <title id={titleId}>{title}</title> : null}<path fillRule="evenodd" clipRule="evenodd" d="M5 13.29a6.316 6.316 0 0 1-3-5.354V3.541a.75.75 0 0 1 .513-.712l5.25-1.75a.75.75 0 0 1 .474 0l5.25 1.75a.75.75 0 0 1 .513.712v4.395c0 2.152-1.12 4.126-3 5.348a7.33 7.33 0 0 1-.2.125l-2.43 1.38a.75.75 0 0 1-.74 0L5.2 13.41a6.375 6.375 0 0 1-.2-.12Zm0-2.887c0 .175.06.347.18.474.29.307.63.576 1.011.795l.003.002 1.435.815c.23.13.512.13.742 0l1.416-.804c.394-.242.74-.523 1.033-.835a.69.69 0 0 0 .18-.475V10a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v.402ZM10 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z" fill="#A1A4A5" /></svg>;
export default Access16Icon;
11 changes: 11 additions & 0 deletions icons/react/Access24Icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SVGProps } from "react";
interface SVGRProps {
title?: string;
titleId?: string;
}
const Access24Icon = ({
title,
titleId,
...props
}: SVGProps<SVGSVGElement> & SVGRProps) => <svg width={24} height={24} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby={titleId} {...props}>{title ? <title id={titleId}>{title}</title> : null}<path fillRule="evenodd" clipRule="evenodd" d="M3 4.72a1 1 0 0 1 .684-.948l8-2.667a1 1 0 0 1 .632 0l8 2.667a1 1 0 0 1 .684.949v8.572a8 8 0 0 1-4.115 6.993l-4.4 2.444a1 1 0 0 1-.97 0l-4.4-2.444A8 8 0 0 1 3 13.293V4.72ZM7 15a3 3 0 0 1 3-3h4a3 3 0 0 1 3 3v1.434a1 1 0 0 1-.485.857l-4 2.4a1 1 0 0 1-1.03 0l-4-2.4A1 1 0 0 1 7 16.434V15Zm5-5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" fill="#A1A4A5" /></svg>;
export default Access24Icon;
Loading