diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..b7e26ab
--- /dev/null
+++ b/src/components/ui/radio-group.tsx
@@ -0,0 +1,35 @@
+import { Radio as RadioPrimitive } from "@base-ui/react/radio"
+import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
+import { cn } from "@/lib/utils"
+
+function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
+ return (
+
+ )
+}
+
+function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { RadioGroup, RadioGroupItem }
diff --git a/src/screens/admin/Permissions.tsx b/src/screens/admin/Permissions.tsx
index 03cd4ba..a1810a8 100644
--- a/src/screens/admin/Permissions.tsx
+++ b/src/screens/admin/Permissions.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '../../ui/Button'
+import { Tick } from '../../ui/Tick'
import { Dialog } from '../../ui/Dialog'
import { SectionHeader } from '../../ui/SectionHeader'
import { SectionIndex } from '../../ui/SectionIndex'
@@ -564,31 +565,31 @@ function PermissionsTable({
{f.name}
- set(f, { canView: e.target.checked })}
- />
+
+ set(f, { canView: v })}
+ />
+
- set(f, { canModify: e.target.checked })}
- />
+
+ set(f, { canModify: v })}
+ />
+
- set(f, { canDelete: e.target.checked })}
- />
+
+ set(f, { canDelete: v })}
+ />
+
onChange(rows.filter((x) => x.name !== f.name))}>Remove
diff --git a/src/screens/blocking/BlockLists.module.css b/src/screens/blocking/BlockLists.module.css
index 96eaf2c..e334a48 100644
--- a/src/screens/blocking/BlockLists.module.css
+++ b/src/screens/blocking/BlockLists.module.css
@@ -63,7 +63,7 @@ row's name goes in `aria-label` instead, as the table checkboxes of Zones do.
It takes BOTH classes `Check` gives its own DOM: `check` on the label (the 26 px
target and the input's alignment) and `toggle` for the switch drawing.
*/
-.switch { composes: check toggle from '../../ui/Check.module.css'; }
+.switch { composes: check from '../../ui/Check.module.css'; }
.name { font-weight: 600; }
/* The URL wraps after its separators (`ui/UrlText`), not at whatever letter meets the
diff --git a/src/screens/blocking/BlockLists.tsx b/src/screens/blocking/BlockLists.tsx
index 8fd8404..e6c2587 100644
--- a/src/screens/blocking/BlockLists.tsx
+++ b/src/screens/blocking/BlockLists.tsx
@@ -9,6 +9,7 @@ import { DataTable } from '../../ui/DataTable'
import { RowAction } from '../../ui/Table'
import { Field, Input, Select } from '../../ui/Field'
import { Button } from '../../ui/Button'
+import { Tick } from '../../ui/Tick'
import { PermissionButton } from '../../ui/PermissionButton'
import { Confirm } from '../../ui/Confirm'
import { Notifier } from '../../ui/Notifier'
@@ -768,9 +769,9 @@ export function BlockLists({
return (
canToggle(l) && (
- setLines((all) => all.map((x, j) => (j === i ? toggleLine(x) : x)))}
diff --git a/src/screens/modals/ChangeTheme.test.tsx b/src/screens/modals/ChangeTheme.test.tsx
index 6e594e9..df03f49 100644
--- a/src/screens/modals/ChangeTheme.test.tsx
+++ b/src/screens/modals/ChangeTheme.test.tsx
@@ -25,7 +25,7 @@ describe('Change Theme (index.html:3818-3862)', () => {
open()
const dialog = screen.getByRole('dialog', { name: 'Change Theme' })
const group = within(dialog).getByRole('radiogroup', { name: 'Theme' })
- expect(within(group).getAllByRole('radio').map((r) => r.closest('label')?.textContent?.trim())).toEqual([
+ expect(within(group).getAllByRole('radio').map((r) => r.getAttribute('aria-label'))).toEqual([
'Use System Theme (default)',
'Light Theme',
'Dark Theme',
@@ -64,7 +64,7 @@ describe('Change Theme (index.html:3818-3862)', () => {
if (stored != null) localStorage.setItem('theme', stored)
open()
expect(radio(label)).toBeChecked()
- expect(screen.getAllByRole('radio').filter((r) => (r as HTMLInputElement).checked)).toHaveLength(1)
+ expect(screen.getAllByRole('radio').filter((r) => r.getAttribute('aria-checked') === 'true')).toHaveLength(1)
})
it('a stored amber checks none, since Amber is not offered', () => {
diff --git a/src/screens/modals/ChangeTheme.tsx b/src/screens/modals/ChangeTheme.tsx
index 1bf5544..bdfd7b9 100644
--- a/src/screens/modals/ChangeTheme.tsx
+++ b/src/screens/modals/ChangeTheme.tsx
@@ -42,9 +42,10 @@ export function ChangeTheme({
return (
-
+
setChoice(v as OfferedTheme)}
diff --git a/src/screens/zones/EditZoneFile.test.tsx b/src/screens/zones/EditZoneFile.test.tsx
index 0a37b4c..8986a75 100644
--- a/src/screens/zones/EditZoneFile.test.tsx
+++ b/src/screens/zones/EditZoneFile.test.tsx
@@ -199,13 +199,13 @@ describe('the Edit Zone File dialog', () => {
draw()
let dialog = await openFromList(user)
await user.click(await within(dialog).findByLabelText('Overwrite SOA Serial'))
- expect(within(dialog).getByLabelText('Overwrite SOA Serial')).toHaveProperty('checked', true)
+ expect(within(dialog).getByLabelText('Overwrite SOA Serial')).toBeChecked()
await user.click(within(dialog).getAllByRole('button', { name: 'Close' }).at(-1)!)
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
dialog = await openFromList(user)
- expect(await within(dialog).findByLabelText('Overwrite SOA Serial')).toHaveProperty('checked', false)
+ expect(await within(dialog).findByLabelText('Overwrite SOA Serial')).not.toBeChecked()
})
it('Save sends the editor as text to zones/import with overwriteZone=true and the checkbox', async () => {
@@ -297,7 +297,7 @@ describe('Import Zone (v15.5)', () => {
const menu = await rowMenu(user, 'primary.test')
await user.click(within(menu).getByRole('menuitem', { name: 'Import Zone' }))
const dialog = await screen.findByRole('dialog', { name: 'Import Zone - primary.test' })
- await user.click(within(dialog).getByLabelText('Text Editor', { selector: 'input' }))
+ await user.click(within(dialog).getByRole('radio', { name: 'Text Editor' }))
await user.type(within(dialog).getByRole('textbox', { name: 'Text Editor' }), 'www 3600 IN A 10.0.0.2')
const listCalls = spy.mock.calls.filter((c) => c[0] === 'zones/list').length
diff --git a/src/screens/zones/ZoneList.tsx b/src/screens/zones/ZoneList.tsx
index bcf2f6a..7419028 100644
--- a/src/screens/zones/ZoneList.tsx
+++ b/src/screens/zones/ZoneList.tsx
@@ -16,6 +16,7 @@ import {
type Zone,
} from '../../api/zones'
import { Button } from '../../ui/Button'
+import { Tick } from '../../ui/Tick'
import { Loading } from '../../ui/Empty'
import { StaleData } from '../StaleData'
import { Field, Input, Select } from '../../ui/Field'
@@ -596,12 +597,11 @@ export function ZoneList({
th: { style: { width: 38 }, className: tbl.checkCell },
header: (
- setChecked(e.target.checked ? zones.map((z) => z.name) : [])}
+ onChange={(v) => setChecked(v ? zones.map((z) => z.name) : [])}
/>
),
@@ -733,15 +733,14 @@ function zoneCells(p: RowProps) {
return {
check: (
- p.onCheck(e.target.checked)}
+ onChange={p.onCheck}
/>
),
diff --git a/src/screens/zones/Zones.module.css b/src/screens/zones/Zones.module.css
index 4fa441a..ea9ed07 100644
--- a/src/screens/zones/Zones.module.css
+++ b/src/screens/zones/Zones.module.css
@@ -52,9 +52,6 @@
.fields { display:flex; flex-direction:column; gap: var(--s-8); }
.inline { composes: inline from '../../ui/PanelForm.module.css'; }
.suffix { composes: trailer from '../../ui/text.module.css'; }
-/* The same row as `ui/Check.module.css`, which is where the minimum height
- lives: without it, the radios of "Add Zone", "Import" and "Convert" were 19 px. */
-.chk { composes: check from '../../ui/Check.module.css'; }
/* The same role as the `.help` of Settings, DHCP and Administration: same colour
and same line height. With `--faint` it read at 4.64:1 and the others at 5.90:1. */
.help { font-size: var(--t-3); color:var(--mute); line-height:1.55; }
diff --git a/src/screens/zones/Zones.test.tsx b/src/screens/zones/Zones.test.tsx
index 97af2da..7754808 100644
--- a/src/screens/zones/Zones.test.tsx
+++ b/src/screens/zones/Zones.test.tsx
@@ -319,9 +319,9 @@ describe('modals', () => {
await user.click(await screen.findByRole('menuitem', { name: 'Convert Zone' }))
const dialog = await screen.findByRole('dialog')
- expect(within(dialog).getByLabelText('Primary Zone')).toHaveProperty('disabled', true)
- expect(within(dialog).getByLabelText('Conditional Forwarder Zone')).toHaveProperty('checked', true)
- expect(within(dialog).getByLabelText('Catalog Zone')).toHaveProperty('disabled', true)
+ expect(within(dialog).getByRole('radio', { name: 'Primary Zone' })).toBeDisabled()
+ expect(within(dialog).getByRole('radio', { name: 'Conditional Forwarder Zone' })).toBeChecked()
+ expect(within(dialog).getByRole('radio', { name: 'Catalog Zone' })).toBeDisabled()
})
it('\"Import Zone\" alerts if no file is chosen', async () => {
diff --git a/src/screens/zones/modals/AddEditRecord.tsx b/src/screens/zones/modals/AddEditRecord.tsx
index f92b857..2fb69d1 100644
--- a/src/screens/zones/modals/AddEditRecord.tsx
+++ b/src/screens/zones/modals/AddEditRecord.tsx
@@ -8,6 +8,8 @@ import {
type ZoneDetails,
} from '../../../api/records'
import { Button } from '../../../ui/Button'
+import { Check } from '../../../ui/Check'
+import { Radios } from '../../../ui/PanelForm'
import { Dialog } from '../../../ui/Dialog'
import { Field, Input, Select, Textarea } from '../../../ui/Field'
import {
@@ -277,10 +279,11 @@ export function AddEditRecord(p: AddEditRecordProps) {
{/* "Overwrite" only exists when adding. */}
{!editing && (
-
- set('overwrite', e.target.checked)} />
- Overwrite existing records
-
+ set('overwrite', v)}
+ />
)}
@@ -370,21 +373,19 @@ function TypeFields({ f, set, setF, apps, classes, onApp, onClass, editing }: Fi
return (
<>
{text(f.type === 'A' ? 'IPv4 Address' : 'IPv6 Address', 'value', { mono: true })}
-
- set('ptr', e.target.checked)} />
- Add reverse (PTR) record
-
-
- {/* Enabled only while the PTR box is ticked (zone.js:543-546); unticking
- the PTR does not untick it. */}
- set('createPtrZone', e.target.checked)}
- />
- Create reverse zone if it does not exists
-
+ set('ptr', v)}
+ />
+ {/* Enabled only while the PTR box is ticked (zone.js:543-546); unticking
+ the PTR does not untick it. */}
+ set('createPtrZone', v)}
+ />
>
)
@@ -418,14 +419,11 @@ function TypeFields({ f, set, setF, apps, classes, onApp, onClass, editing }: Fi
{text('Retry', 'soaRetry', { mono: true, short: true })}
{text('Expire', 'soaExpire', { mono: true, short: true })}
{text('Minimum', 'soaMinimum', { mono: true, short: true })}
-
- set('soaUseSerialDateScheme', e.target.checked)}
- />
- Use Serial Date Scheme
-
+ set('soaUseSerialDateScheme', v)}
+ />
>
)
@@ -463,14 +461,11 @@ function TypeFields({ f, set, setF, apps, classes, onApp, onClass, editing }: Fi
/>
)}
-
- set('txtSplitText', e.target.checked)}
- />
- Split text into multiple character strings
-
+ set('txtSplitText', v)}
+ />
>
)
@@ -629,22 +624,16 @@ MII...
-
- set('svcbAutoIpv4Hint', e.target.checked)}
- />
- Use Automatic IPv4 Hint
-
-
- set('svcbAutoIpv6Hint', e.target.checked)}
- />
- Use Automatic IPv6 Hint
-
+ set('svcbAutoIpv4Hint', v)}
+ />
+ set('svcbAutoIpv6Hint', v)}
+ />
>
)
@@ -670,32 +659,21 @@ MII...
return (
<>
- {FORWARDER_PROTOCOLS.map((x) => (
-
- set('forwarderProtocol', x.value)}
- />
- {x.label}
-
- ))}
+ set('forwarderProtocol', v as typeof f.forwarderProtocol)}
+ options={FORWARDER_PROTOCOLS.map((x) => ({ value: x.value, label: x.label }))}
+ />
{/* `updateAddEditFormForwarderThisServer` (zone.js:5336-5356): ticked, the
forwarder is `this-server`, fixed, on UDP, and the proxy goes away. */}
-
- {
- const on = e.target.checked
- setF((prev) => toggleForwarderThisServer(prev, on))
- }}
- />
- Use "This Server"
-
+ setF((prev) => toggleForwarderThisServer(prev, on))}
+ />
When using "This Server", if a record does not exists in the zone then the request is
forwarded to the DNS Server's resolver internally. This allows you to override any record for
@@ -719,27 +697,19 @@ MII...
queried before trying for forwarder with high priority value. Forwarders with the same
priority value will be queried concurrently.
-
- set('forwarderDnssecValidation', e.target.checked)}
- />
- Enable DNSSEC Validation
-
+ set('forwarderDnssecValidation', v)}
+ />
{!f.forwarderThisServer && (
- {PROXY_TYPES.map((x) => (
-
- set('proxyType', x.value)}
- />
- {x.label}
-
- ))}
+ set('proxyType', v as typeof f.proxyType)}
+ options={PROXY_TYPES.map((x) => ({ value: x.value, label: x.label }))}
+ />
{(id) => (
- {ADD_TYPES.map((t) => (
-
- changeType(t.value)}
- />
- {/*
- `.chk` is `display:flex`, so every child of the label is an item:
- without this `span` the link and the brackets split into three boxes
- ("Secondary ROOT Zone ( RFC 8806 )"), and the link stopped being
- inside a sentence, which is what grants it the target-size
- exception. Wrapped, it is text again.
- */}
-
- {t.label}
- {t.reference && (
- <>
- {' ('}
- {t.reference.text}
- {')'}
- >
- )}
-
-
- ))}
+ {/* A link inside an option stays inside its sentence: wrapped in a span, the
+ link and the brackets are text again and not three boxes, which is what
+ grants the link the target-size exception. */}
+ changeType(v as AddZoneKind)}
+ options={ADD_TYPES.map((t) => ({
+ value: t.value,
+ name: t.label,
+ label: (
+
+ {t.label}
+ {t.reference && (
+ <>
+ {' ('}
+ {t.reference.text}
+ {')'}
+ >
+ )}
+
+ ),
+ }))}
+ />
{v.catalog && catalogs.length > 0 && (
@@ -213,14 +212,11 @@ export function AddZone({
{v.initializeForwarderCheckbox && (
-
- set('initializeForwarder', e.target.checked)}
- />
- Initialize Forwarder (FWD) Record
-
+ set('initializeForwarder', v)}
+ />
)}
@@ -240,14 +236,11 @@ export function AddZone({
{v.soaSerial && (
-
- set('useSoaSerialDateScheme', e.target.checked)}
- />
- Use SOA Serial Date Scheme
-
+ set('useSoaSerialDateScheme', v)}
+ />
)}
@@ -280,17 +273,12 @@ export function AddZone({
{v.transferProtocol && (
- {TRANSFER_PROTOCOLS.map((p) => (
-
- set('zoneTransferProtocol', p.value)}
- />
- {p.label}
-
- ))}
+ set('zoneTransferProtocol', v as typeof f.zoneTransferProtocol)}
+ options={TRANSFER_PROTOCOLS.map((p) => ({ value: p.value, label: p.label }))}
+ />
)}
@@ -311,37 +299,30 @@ export function AddZone({
{v.validateZone && (
-
- set('validateZone', e.target.checked)}
- />
- Use ZONEMD to Validate Zone
-
-
- When enabled, the secondary zone will be validated using the ZONEMD record after every
- zone transfer. The zone will get disabled if the validation fails. The zone must be DNSSEC
- signed for the validation to work.
-
+
+ Use ZONEMD to Validate Zone
+ >
+ }
+ checked={f.validateZone}
+ onChange={(v) => set('validateZone', v)}
+ help="When enabled, the secondary zone will be validated using the ZONEMD record after every zone transfer. The zone will get disabled if the validation fails. The zone must be DNSSEC signed for the validation to work."
+ />
)}
{v.forwarderFields && (
<>
- {FORWARDER_PROTOCOLS.map((p) => (
-
- set('forwarderProtocol', p.value)}
- />
- {p.label}
-
- ))}
+ set('forwarderProtocol', v as typeof f.forwarderProtocol)}
+ options={FORWARDER_PROTOCOLS.map((p) => ({ value: p.value, label: p.label }))}
+ />
{/* Two help paragraphs: upstream's two, one about "This Server" and
@@ -373,52 +354,40 @@ export function AddZone({
value={f.useThisServer ? 'this-server' : f.forwarder}
onChange={(e) => set('forwarder', e.target.value)}
/>
-
- {
- /* `updateAddZoneFormForwarderThisServer` (zone.js:2979-2998):
- ticked, the protocol goes to UDP; unticked, the field is emptied. */
- const on = e.target.checked
- setF((prev) =>
- on
- ? { ...prev, useThisServer: true, forwarderProtocol: 'Udp' }
- : { ...prev, useThisServer: false, forwarder: '' },
- )
- }}
- />
- Use "This Server"
-
+ {
+ /* `updateAddZoneFormForwarderThisServer` (zone.js:2979-2998):
+ ticked, the protocol goes to UDP; unticked, the field is emptied. */
+ setF((prev) =>
+ on
+ ? { ...prev, useThisServer: true, forwarderProtocol: 'Udp' }
+ : { ...prev, useThisServer: false, forwarder: '' },
+ )
+ }}
+ />
>
)}
-
- set('dnssecValidation', e.target.checked)}
- />
- Enable DNSSEC Validation
-
+ set('dnssecValidation', v)}
+ />
{/* "this-server" takes no proxy: upstream hides the whole block. */}
{!f.useThisServer && (
- {PROXY_TYPES.map((p) => (
-
- set('proxyType', p.value)}
- />
- {p.label}
-
- ))}
+ set('proxyType', v as typeof f.proxyType)}
+ options={PROXY_TYPES.map((p) => ({ value: p.value, label: p.label }))}
+ />
{(id) => (
- {(['Primary', 'Forwarder', 'Catalog'] as ConversionTarget[]).map((d) => (
-
- setTarget(d)}
- />
- {LABELS[d]}
-
- ))}
+ setTarget(v as ConversionTarget)}
+ options={(['Primary', 'Forwarder', 'Catalog'] as ConversionTarget[]).map((d) => ({
+ value: d,
+ label: LABELS[d],
+ disabled: !table.enabled2.includes(d),
+ }))}
+ />
diff --git a/src/screens/zones/modals/DnssecProperties.tsx b/src/screens/zones/modals/DnssecProperties.tsx
index faf9e70..7808f65 100644
--- a/src/screens/zones/modals/DnssecProperties.tsx
+++ b/src/screens/zones/modals/DnssecProperties.tsx
@@ -21,6 +21,7 @@ import {
} from '../../../api/dnssec'
import { Alert } from '../../../ui/Alert'
import { Button } from '../../../ui/Button'
+import { Radios } from '../../../ui/PanelForm'
import { Dialog } from '../../../ui/Dialog'
import { Field, Input, Select, Textarea } from '../../../ui/Field'
import { Loading } from '../../../ui/Empty'
@@ -535,25 +536,20 @@ export function DnssecProperties({
)}
- {GENERATIONS.map((g) => (
-
-
- // zone.js:501-526: the rollover follows, and the PEM is cleared.
- setNewKey((k) => ({
- ...k,
- generation: g.value,
- pem: '',
- rolloverDays: addKeyRollover(k.keyType, g.value),
- }))
- }
- />
- {g.label}
-
- ))}
+
+ // zone.js:501-526: the rollover follows, and the PEM is cleared.
+ setNewKey((k) => ({
+ ...k,
+ generation: v as typeof k.generation,
+ pem: '',
+ rolloverDays: addKeyRollover(k.keyType, v as typeof k.generation),
+ }))
+ }
+ options={GENERATIONS.map((g) => ({ value: g.value, label: g.label }))}
+ />
{newKey.algorithm === 'RSA' && newKey.generation === 'Automatic' && (
@@ -635,20 +631,12 @@ MII...
Proof of Non-Existence
- {NX_PROOFS.map((n) => (
-
-
- setNxProof(n.value as NxProof)}
- />
- {n.label}
-
-
{n.help}
-
- ))}
+
setNxProof(v as NxProof)}
+ options={NX_PROOFS.map((n) => ({ value: n.value, label: n.label, help: n.help }))}
+ />
{nxProof === 'NSEC3' && (
<>
diff --git a/src/screens/zones/modals/EditZoneFile.tsx b/src/screens/zones/modals/EditZoneFile.tsx
index efb59d4..204d22b 100644
--- a/src/screens/zones/modals/EditZoneFile.tsx
+++ b/src/screens/zones/modals/EditZoneFile.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { exportZoneText, saveZoneFile } from '../../../api/zones'
import { Alert } from '../../../ui/Alert'
import { Button } from '../../../ui/Button'
+import { Check } from '../../../ui/Check'
import { Dialog } from '../../../ui/Dialog'
import { Loading } from '../../../ui/Empty'
import { LabeledTextarea } from '../../../ui/Field'
@@ -132,18 +133,12 @@ export function EditZoneFile({
-
- setOverwriteSoaSerial(e.target.checked)}
- />
- Overwrite SOA Serial
-
-
- Enable this option to overwrite existing SOA record serial with the SOA record serial
- specified in the zone file editor.
-
+
diff --git a/src/screens/zones/modals/ImportZone.tsx b/src/screens/zones/modals/ImportZone.tsx
index 5057bbe..6ee5448 100644
--- a/src/screens/zones/modals/ImportZone.tsx
+++ b/src/screens/zones/modals/ImportZone.tsx
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'
import { importZone } from '../../../api/zones'
import { Alert } from '../../../ui/Alert'
import { Button } from '../../../ui/Button'
+import { Radios } from '../../../ui/PanelForm'
+import { Check } from '../../../ui/Check'
import { Dialog } from '../../../ui/Dialog'
import { LabeledTextarea } from '../../../ui/Field'
import type { Notice } from '../types'
@@ -107,52 +109,36 @@ export function ImportZone({
-
- setOverwrite(e.target.checked)} />
- Overwrite Existing Records
-
-
- Enable this option to overwrite existing records for the record types being imported.
-
-
- setOverwriteZone(e.target.checked)} />
- Overwrite Zone
-
-
- Enable this option to delete all existing records from the zone before importing new records.
-
-
- setOverwriteSoaSerial(e.target.checked)}
- />
- Overwrite SOA Serial
-
-
- Enable this option to overwrite existing SOA record serial with the imported SOA record serial.
-
+
+
+
-
- setMode('File')}
- />
- Zone File
-
-
- setMode('Text')}
- />
- Text Editor
-
+ setMode(v as typeof mode)}
+ options={[
+ { value: 'File', label: 'Zone File' },
+ { value: 'Text', label: 'Text Editor' },
+ ]}
+ />
{/* Both stay mounted and the other one is hidden, as upstream does
diff --git a/src/screens/zones/modals/SignZone.tsx b/src/screens/zones/modals/SignZone.tsx
index da882d1..64690d6 100644
--- a/src/screens/zones/modals/SignZone.tsx
+++ b/src/screens/zones/modals/SignZone.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { signZone, type Algorithm, type NxProof } from '../../../api/dnssec'
import { Button } from '../../../ui/Button'
+import { Radios } from '../../../ui/PanelForm'
import { Dialog } from '../../../ui/Dialog'
import { Field, Input, Select, Textarea } from '../../../ui/Field'
import {
@@ -155,17 +156,12 @@ export function SignZone({
- {ALGORITHMS.map((a) => (
-
- changeAlgorithm(a.value as Algorithm)}
- />
- {a.label}
-
- ))}
+ changeAlgorithm(v as Algorithm)}
+ options={ALGORITHMS.map((a) => ({ value: a.value, label: a.label }))}
+ />
{isRsa ? (
@@ -237,20 +233,12 @@ export function SignZone({
/>
- {NX_PROOFS.map((n) => (
-
-
- set('nxProof', n.value as NxProof)}
- />
- {n.label}
-
-
{n.help}
-
- ))}
+ set('nxProof', v as NxProof)}
+ options={NX_PROOFS.map((n) => ({ value: n.value, label: n.label, help: n.help }))}
+ />
{f.nxProof === 'NSEC3' && (
@@ -378,17 +366,12 @@ function SigningKey({
return (
{title}
- {GENERATIONS.map((g) => (
-
- onGeneration(g.value)}
- />
- {g.label}
-
- ))}
+
({ value: g.value, label: g.label }))}
+ />
{/* The size only applies to RSA and only with automatic generation. */}
{isRsa && generation === 'Automatic' && (
diff --git a/src/screens/zones/modals/ZoneOptions.tsx b/src/screens/zones/modals/ZoneOptions.tsx
index a716c46..8056b76 100644
--- a/src/screens/zones/modals/ZoneOptions.tsx
+++ b/src/screens/zones/modals/ZoneOptions.tsx
@@ -2,6 +2,8 @@ import { useEffect, useState, type ReactNode } from 'react'
import { getZoneOptions, setZoneOptions, type ZoneOptions as Response } from '../../../api/zones'
import { Alert } from '../../../ui/Alert'
import { Button } from '../../../ui/Button'
+import { Check } from '../../../ui/Check'
+import { Radios } from '../../../ui/PanelForm'
import { Dialog } from '../../../ui/Dialog'
import { Field, Input, Select, Textarea } from '../../../ui/Field'
import { Loading } from '../../../ui/Empty'
@@ -188,46 +190,31 @@ export function ZoneOptions({
Select a Catalog zone to register as its member zone.
{e.overrideQueryAccess && (
-
-
- set('overrideCatalogQueryAccess', ev.target.checked)}
- />
- Override Query Access Option
-
-
Enable to override Query Access option in the Catalog zone.
-
+ set('overrideCatalogQueryAccess', v)}
+ help="Enable to override Query Access option in the Catalog zone."
+ />
)}
{e.overrideZoneTransfer && (
-
-
- set('overrideCatalogZoneTransfer', ev.target.checked)}
- />
- Override Zone Transfer Option
-
-
Enable to override Zone Transfer option in the Catalog zone.
-
+ set('overrideCatalogZoneTransfer', v)}
+ help="Enable to override Zone Transfer option in the Catalog zone."
+ />
)}
{e.overrideNotify && (
-
-
- set('overrideCatalogNotify', ev.target.checked)}
- />
- Override Notify Option
-
-
Enable to override Notify option in the Catalog zone.
-
+ set('overrideCatalogNotify', v)}
+ help="Enable to override Notify option in the Catalog zone."
+ />
)}
When a zone becomes a member of a Catalog zone, all of the Catalog zone's Options are
@@ -264,18 +251,13 @@ export function ZoneOptions({
{e.xfrProtocol && (
- {XFR_PROTOCOLS.map((x) => (
-
- set('primaryZoneTransferProtocol', x.value)}
- />
- {x.label}
-
- ))}
+ set('primaryZoneTransferProtocol', v as typeof f.primaryZoneTransferProtocol)}
+ options={XFR_PROTOCOLS.map((x) => ({ value: x.value, label: x.label }))}
+ />
)}
@@ -300,23 +282,19 @@ export function ZoneOptions({
)}
{e.validateZone && (
-
-
- set('validateZone', ev.target.checked)}
- />
- Use ZONEMD to Validate Zone
-
- {/* index.html:5402 in v15.5.1. */}
-
- When enabled, the secondary zone will be validated using the ZONEMD record after every
- zone transfer. The zone will get disabled if the validation fails. The zone must be DNSSEC
- signed for the validation to work.
-
-
+
+ Use ZONEMD to Validate Zone
+ >
+ }
+ disabled={e.primaryServerLocked}
+ checked={f.validateZone}
+ onChange={(v) => set('validateZone', v)}
+ /* index.html:5402 in v15.5.1. */
+ help="When enabled, the secondary zone will be validated using the ZONEMD record after every zone transfer. The zone will get disabled if the validation fails. The zone must be DNSSEC signed for the validation to work."
+ />
)}
>
)}
@@ -616,21 +594,13 @@ function Criterion({
return (
<>
- {options.map((o) => (
-
-
- onChanged2(o.value)}
- />
- {o.label}
-
-
{o.help}
-
- ))}
+
({ value: o.value, label: o.label, help: o.help }))}
+ />
{(id) => (
diff --git a/src/screens/zones/modals/ZonePermissions.tsx b/src/screens/zones/modals/ZonePermissions.tsx
index 3ac9430..f830ac6 100644
--- a/src/screens/zones/modals/ZonePermissions.tsx
+++ b/src/screens/zones/modals/ZonePermissions.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { getZonePermissions, serializePermissions, setZonePermissions } from '../../../api/zones'
import { Button } from '../../../ui/Button'
+import { Tick } from '../../../ui/Tick'
import { Dialog } from '../../../ui/Dialog'
import { Field, Select } from '../../../ui/Field'
import { Loading } from '../../../ui/Empty'
@@ -191,13 +192,9 @@ function PermissionsTable({
th: { style: { width: 70 } },
header: ['View', 'Modify', 'Delete'][n],
cell: (f: (typeof rows)[number], i: number) => (
- change(i, key, e.target.checked)}
- />
+
+ change(i, key, v)} />
+
),
})),
{
diff --git a/src/ui/Check.tsx b/src/ui/Check.tsx
index de179d0..9b53b8b 100644
--- a/src/ui/Check.tsx
+++ b/src/ui/Check.tsx
@@ -25,8 +25,12 @@ export function Check({
help,
disabled,
toggle = false,
+ text,
}: {
+ /** The control's accessible name, and the visible text unless `text` replaces it. */
label: string
+ /** Visible text with markup in it (a link), when `label` alone would lose it. */
+ text?: ReactNode
checked: boolean
onChange: (v: boolean) => void
help?: ReactNode
@@ -52,8 +56,15 @@ export function Check({
) : (
} {...common} />
)}
- !disabled && onChange(!checked)}>
- {label}
+ {
+ // A link inside the text is followed, not a click on the box.
+ if (disabled || (e.target as HTMLElement).closest('a')) return
+ onChange(!checked)
+ }}
+ >
+ {text ?? label}
{help &&
{help}
}
diff --git a/src/ui/PanelForm.tsx b/src/ui/PanelForm.tsx
index d38af8b..a995b1d 100644
--- a/src/ui/PanelForm.tsx
+++ b/src/ui/PanelForm.tsx
@@ -1,4 +1,5 @@
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Alert } from './Alert'
import { Button } from './Button'
import { Empty } from './Empty'
@@ -242,14 +243,21 @@ export function Trailer({ children }: { children: ReactNode }) {
export interface RadioOption {
value: string
- label: string
+ /** The option's text; with markup in it (a link), give `name` as well. */
+ label: ReactNode
+ /** The option's accessible name when `label` is not a string. */
+ name?: string
help?: ReactNode
+ disabled?: boolean
}
/*
-The radio group. It shares its row and its help with `ui/Check`: they are the
-same control with different cardinality, and when each screen wrote its own, a
-radio's help and a checkbox's help did not land on the same left edge.
+The radio group, ReUI's `RadioGroup`. It shares its row and its help with `ui/Check`:
+they are the same control with different cardinality, and when each screen wrote
+its own, a radio's help and a checkbox's help did not land on the same left edge.
+
+The item carries its own name and the text beside it only forwards the click, as in
+`Check`: a wrapping
would name the hidden input as well.
*/
export function Radios({
name,
@@ -257,32 +265,49 @@ export function Radios({
options,
onChange,
disabled,
+ label,
}: {
name: string
value: string
options: RadioOption[]
onChange: (v: string) => void
disabled?: boolean
+ /** The group's accessible name, when its heading is not a `GroupRow` label. */
+ label?: string
}) {
return (
- <>
+ onChange(String(v))}
+ className="gap-0"
+ >
{options.map((o) => (
-
-
+ onChange(o.value)}
+ nativeButton
+ render={ }
+ disabled={o.disabled}
+ aria-label={o.name ?? (typeof o.label === 'string' ? o.label : o.value)}
/>
- {o.label}
-
+ {
+ if (disabled || o.disabled || (e.target as HTMLElement).closest('a')) return
+ onChange(o.value)
+ }}
+ >
+ {o.label}
+
+
{o.help && {o.help}
}
))}
- >
+
)
}
diff --git a/src/ui/Tick.tsx b/src/ui/Tick.tsx
new file mode 100644
index 0000000..6817028
--- /dev/null
+++ b/src/ui/Tick.tsx
@@ -0,0 +1,38 @@
+import { Checkbox } from '@/components/ui/checkbox'
+import { Switch } from '@/components/ui/switch'
+
+/*
+The bare control, with no text beside it: a table cell's tick (a row selection, a
+permission), or the enable switch of a list row. It is `ui/Check` without the row and
+its label, and it is named the same way: through `aria-label`, announced as a
+checkbox either way (a switch too), so the tests and the keyboard keep one role.
+*/
+export function Tick({
+ label,
+ checked,
+ onChange,
+ disabled,
+ toggle = false,
+ indeterminate,
+}: {
+ /** The accessible name: there is no visible text. */
+ label: string
+ checked: boolean
+ onChange: (v: boolean) => void
+ disabled?: boolean
+ /** A setting that stays put (a switch), not a selection. */
+ toggle?: boolean
+ indeterminate?: boolean
+}) {
+ const common = {
+ checked,
+ disabled,
+ 'aria-label': label,
+ onCheckedChange: (v: boolean) => onChange(v),
+ }
+ return toggle ? (
+
} {...common} />
+ ) : (
+
} indeterminate={indeterminate} {...common} />
+ )
+}